/* ===== engine.js ===== */
/* ==================== engine.js ==================== */
(function (root) {
"use strict";
const YAOCHUU = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33];
const HONOR_START = 27;
function tileSuit(t) {
if (t < 9) return "m";
if (t < 18) return "p";
if (t < 27) return "s";
return "z";
}
function tileNum(t) {
if (t < 27) return (t % 9) + 1;
return t - 26;
}
function isTerminalOrHonor(t) {
if (t >= HONOR_START) return true;
const n = tileNum(t);
return n === 1 || n === 9;
}
function isTerminal(t) {
if (t >= HONOR_START) return false;
const n = tileNum(t);
return n === 1 || n === 9;
}
function isHonor(t) { return t >= HONOR_START; }
function isGreenTile(t) {
const greens = [19, 20, 21, 23, 25, 32];
return greens.indexOf(t) !== -1;
}
function newCounts() { return new Array(34).fill(0); }
function countsFromArray(tiles) { const c = newCounts(); for (const t of tiles) c[t]++; return c; }
function cloneCounts(c) { return c.slice(); }
function chiitoiShanten(counts) {
let pairs = 0, kinds = 0;
for (let i = 0; i < 34; i++) { if (counts[i] > 0) kinds++; if (counts[i] >= 2) pairs++; }
return 6 - pairs + Math.max(0, 7 - kinds);
}
function kokushiShanten(counts) {
let kinds = 0, hasPair = false;
for (const t of YAOCHUU) { if (counts[t] > 0) kinds++; if (counts[t] >= 2) hasPair = true; }
return 13 - kinds - (hasPair ? 1 : 0);
}
const suitMemo = new Map();
function decomposeSuit(counts9) {
const key = counts9.join("");
if (suitMemo.has(key)) return suitMemo.get(key);
const result = decomposeSuitRec(counts9.slice(), 0);
suitMemo.set(key, result);
return result;
}
function score(r) { return r.sets * 2 + r.partials; }
function better(a, b) { return score(a) > score(b) ? a : b; }
function decomposeSuitRec(c, i) {
while (i < 9 && c[i] === 0) i++;
if (i >= 9) return { sets: 0, partials: 0 };
let best = { sets: 0, partials: 0 };
if (c[i] >= 3) { const c2 = c.slice(); c2[i] -= 3; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets + 1, partials: r.partials }); }
if (i <= 6 && c[i] >= 1 && c[i + 1] >= 1 && c[i + 2] >= 1) { const c2 = c.slice(); c2[i]--; c2[i + 1]--; c2[i + 2]--; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets + 1, partials: r.partials }); }
if (c[i] >= 2) { const c2 = c.slice(); c2[i] -= 2; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets, partials: r.partials + 1 }); }
if (i <= 7 && c[i] >= 1 && c[i + 1] >= 1) { const c2 = c.slice(); c2[i]--; c2[i + 1]--; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets, partials: r.partials + 1 }); }
if (i <= 6 && c[i] >= 1 && c[i + 2] >= 1) { const c2 = c.slice(); c2[i]--; c2[i + 2]--; const r = decomposeSuitRec(c2, i); best = better(best, { sets: r.sets, partials: r.partials + 1 }); }
{ const c2 = c.slice(); c2[i] = 0; const r = decomposeSuitRec(c2, i); best = better(best, r); }
return best;
}
function decomposeHonors(counts) {
let sets = 0, partials = 0;
for (let t = 27; t < 34; t++) { if (counts[t] >= 3) sets++; else if (counts[t] === 2) partials++; }
return { sets, partials };
}
function standardShanten(counts, meldCount) {
meldCount = meldCount || 0;
let best = 8;
const pairCandidates = [null];
for (let i = 0; i < 34; i++) if (counts[i] >= 2) pairCandidates.push(i);
for (const pc of pairCandidates) {
const work = counts.slice();
let hasPair = 0;
if (pc !== null) { work[pc] -= 2; hasPair = 1; }
let sets = meldCount, partials = 0;
const suits = [work.slice(0, 9), work.slice(9, 18), work.slice(18, 27)];
for (const s of suits) { const r = decomposeSuit(s); sets += r.sets; partials += r.partials; }
const h = decomposeHonors(work);
sets += h.sets; partials += h.partials;
let blocks = sets + partials;
if (blocks > 4) { partials = 4 - sets; if (partials < 0) partials = 0; blocks = sets + partials; }
let shanten = 8 - 2 * sets - partials - hasPair;
if (best > shanten) best = shanten;
}
return best;
}
function calcShanten(counts, meldCount) {
let s = standardShanten(counts, meldCount || 0);
if (!meldCount) { s = Math.min(s, chiitoiShanten(counts)); s = Math.min(s, kokushiShanten(counts)); }
return s;
}
function ukeire(counts, meldCount) {
const cur = calcShanten(counts, meldCount);
const res = [];
for (let t = 0; t < 34; t++) {
if (counts[t] >= 4) continue;
const c2 = counts.slice(); c2[t]++;
if (calcShanten(c2, meldCount) < cur) res.push(t);
}
return res;
}
function isCompleteStandard(counts, meldCount) {
meldCount = meldCount || 0;
const needSets = 4 - meldCount;
for (let p = 0; p < 34; p++) {
if (counts[p] >= 2) { const w = counts.slice(); w[p] -= 2; if (canDecomposeAllSets(w, needSets)) return true; }
}
return false;
}
function canDecomposeAllSets(counts, needSets) {
let total = 0; for (const c of counts) total += c;
if (total === 0) return needSets === 0;
if (needSets === 0) return total === 0;
let i = 0; while (i < 34 && counts[i] === 0) i++;
if (i >= 34) return needSets === 0;
if (isHonor(i)) {
if (counts[i] === 3) { const w = counts.slice(); w[i] -= 3; return canDecomposeAllSets(w, needSets - 1); }
return false;
}
const posInSuit = i % 9;
if (counts[i] >= 3) { const w = counts.slice(); w[i] -= 3; if (canDecomposeAllSets(w, needSets - 1)) return true; }
if (posInSuit <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { const w = counts.slice(); w[i]--; w[i + 1]--; w[i + 2]--; if (canDecomposeAllSets(w, needSets - 1)) return true; }
return false;
}
function isChiitoiComplete(counts) {
let pairs = 0, kinds = 0;
for (const c of counts) { if (c > 0) kinds++; if (c === 2) pairs++; if (c > 2) return false; }
return pairs === 7 && kinds === 7;
}
function isKokushiComplete(counts) {
let hasPair = false;
for (let t = 0; t < 34; t++) { if (counts[t] === 0) continue; if (YAOCHUU.indexOf(t) === -1) return false; if (counts[t] >= 2) hasPair = true; }
let kinds = 0; for (const t of YAOCHUU) if (counts[t] > 0) kinds++;
return kinds === 13 && hasPair;
}
function isAgari(counts, meldCount) {
return isCompleteStandard(counts, meldCount || 0) || (!meldCount && isChiitoiComplete(counts)) || (!meldCount && isKokushiComplete(counts));
}
root.MJCore = {
tileSuit, tileNum, isTerminalOrHonor, isTerminal, isHonor, isGreenTile,
newCounts, countsFromArray, cloneCounts,
chiitoiShanten, kokushiShanten, standardShanten, calcShanten, ukeire,
isCompleteStandard, isChiitoiComplete, isKokushiComplete, isAgari, YAOCHUU,
};
})(window);
/* ===== yaku.js ===== */
/* ==================== yaku.js ==================== */
window.MJYaku = (function (Core) {
"use strict";
function isHonor(t) { return t >= 27; }
function tileNum(t) { return Core.tileNum(t); }
function isTerminal(t) { return Core.isTerminal(t); }
function isTerminalOrHonor(t) { return Core.isTerminalOrHonor(t); }
function collectSetDecomps(counts, needSets) {
const results = []; const cur = []; const c = counts.slice();
function rec(i) {
while (i < 34 && c[i] === 0) i++;
if (cur.length === needSets) { for (let k = i; k < 34; k++) if (c[k] !== 0) return; results.push(cur.slice()); return; }
if (i >= 34) return;
if (isHonor(i)) {
if (c[i] >= 3) { c[i] -= 3; cur.push({ type: "triplet", tile: i, open: false, kan: false }); rec(i); cur.pop(); c[i] += 3; }
return;
}
const pos = i % 9;
if (c[i] >= 3) { c[i] -= 3; cur.push({ type: "triplet", tile: i, open: false, kan: false }); rec(i); cur.pop(); c[i] += 3; }
if (pos <= 6 && c[i] >= 1 && c[i + 1] >= 1 && c[i + 2] >= 1) { c[i]--; c[i + 1]--; c[i + 2]--; cur.push({ type: "sequence", tile: i, open: false }); rec(i); cur.pop(); c[i]++; c[i + 1]++; c[i + 2]++; }
}
rec(0);
return results;
}
function allHandDecompositions(counts14, meldCount) {
const needSets = 4 - meldCount; const decomps = [];
for (let p = 0; p < 34; p++) {
if (counts14[p] >= 2) {
const c = counts14.slice(); c[p] -= 2;
const total = c.reduce((a, b) => a + b, 0);
if (total !== 3 * needSets) continue;
const sub = collectSetDecomps(c, needSets);
for (const groups of sub) decomps.push({ pair: p, groups });
}
}
return decomps;
}
function meldToGroup(meld) {
if (meld.type === "chi") { const sorted = meld.tiles.slice().sort((a, b) => a - b); return { type: "sequence", tile: sorted[0], open: true, kan: false }; }
if (meld.type === "pon") return { type: "triplet", tile: meld.tiles[0], open: true, kan: false };
if (meld.type === "minkan") return { type: "triplet", tile: meld.tiles[0], open: true, kan: true };
if (meld.type === "kakan") return { type: "triplet", tile: meld.tiles[0], open: true, kan: true };
if (meld.type === "ankan") return { type: "triplet", tile: meld.tiles[0], open: false, kan: true };
throw new Error("unknown meld type " + meld.type);
}
function waitShape(group, winNum) {
const a = group.tile % 9; const an = a + 1;
if (winNum === an + 1) return "kanchan";
if (winNum === an) { if (an === 7) return "penchan"; return "ryanmen"; }
if (winNum === an + 2) { if (an === 1) return "penchan"; return "ryanmen"; }
return "ryanmen";
}
function windTileFor(idx) { return 27 + idx; }
const DRAGONS = [31, 32, 33];
function evaluateDecomp(ctx, allGroups, pairTile) {
const {
winTile, isTsumo, isMenzen, isRiichi, isDoubleRiichi, isIppatsu,
isHaitei, isHoutei, isRinshan, isChankan, seatWindIdx, roundWindIdx,
doraIndicators, uraDoraIndicators, akaCount,
} = ctx;
const winNum = tileNum(winTile);
let winGroupIdx = -1;
for (let i = 0; i < allGroups.length; i++) {
const g = allGroups[i];
if (g.type === "triplet") { if (g.tile === winTile) { winGroupIdx = i; break; } }
else { const a = g.tile; if ((winTile === a || winTile === a + 1 || winTile === a + 2) && Core.tileSuit(winTile) === Core.tileSuit(a)) { winGroupIdx = i; break; } }
}
let waitType = "tanki";
if (winGroupIdx >= 0) {
const wg = allGroups[winGroupIdx];
waitType = wg.type === "sequence" ? waitShape(wg, winNum) : "shanpon";
}
const isAllSequence = allGroups.every((g) => g.type === "sequence");
const yaku = []; let yakumanCount = 0;
const pairIsYakuhai = DRAGONS.indexOf(pairTile) !== -1 || pairTile === windTileFor(seatWindIdx) || pairTile === windTileFor(roundWindIdx);
const isPinfu = isMenzen && isAllSequence && !pairIsYakuhai && waitType === "ryanmen";
if (isPinfu) yaku.push({ name: "平和", han: 1 });
const noTerminalHonor = allGroups.every((g) => g.type === "sequence" ? (!isTerminalOrHonor(g.tile) && !isTerminalOrHonor(g.tile + 2)) : !isTerminalOrHonor(g.tile)) && !isTerminalOrHonor(pairTile);
if (noTerminalHonor) yaku.push({ name: "断幺九", han: 1 });
if (isMenzen) {
const seqKeys = allGroups.filter((g) => g.type === "sequence" && !g.open).map((g) => g.tile);
const seen = {}; let iipeikou = 0;
for (const k of seqKeys) seen[k] = (seen[k] || 0) + 1;
for (const k in seen) if (seen[k] >= 2) iipeikou += Math.floor(seen[k] / 2);
if (iipeikou > 0) yaku.push({ name: "一盃口", han: iipeikou });
}
let yakuhaiHan = 0;
for (const g of allGroups) {
if (g.type !== "triplet") continue;
if (DRAGONS.indexOf(g.tile) !== -1) yakuhaiHan += 1;
if (g.tile === windTileFor(seatWindIdx)) yakuhaiHan += 1;
if (g.tile === windTileFor(roundWindIdx)) yakuhaiHan += 1;
}
if (yakuhaiHan > 0) yaku.push({ name: "役牌", han: yakuhaiHan });
{
const seqNums = {};
for (const g of allGroups) { if (g.type !== "sequence") continue; const suit = Core.tileSuit(g.tile); const num = g.tile % 9; seqNums[num] = seqNums[num] || new Set(); seqNums[num].add(suit); }
let found = false, allConcealed = true;
for (const num in seqNums) if (seqNums[num].size === 3) { found = true; for (const g of allGroups) if (g.type === "sequence" && g.tile % 9 === Number(num) && g.open) allConcealed = false; }
if (found) yaku.push({ name: "三色同順", han: allConcealed ? 2 : 1 });
}
{
const bySuit = { m: new Set(), p: new Set(), s: new Set() };
for (const g of allGroups) { if (g.type !== "sequence") continue; const suit = Core.tileSuit(g.tile); if (bySuit[suit]) bySuit[suit].add(g.tile % 9); }
let found = false;
for (const suit in bySuit) if (bySuit[suit].has(0) && bySuit[suit].has(3) && bySuit[suit].has(6)) found = true;
let concealed = true;
if (found) { for (const g of allGroups) if (g.type === "sequence" && [0, 3, 6].indexOf(g.tile % 9) !== -1 && g.open) concealed = false; yaku.push({ name: "一気通貫", han: concealed ? 2 : 1 }); }
}
{
const allHaveTermHonor = allGroups.every((g) => g.type === "sequence" ? (isTerminalOrHonor(g.tile) || isTerminalOrHonor(g.tile + 2)) : isTerminalOrHonor(g.tile)) && isTerminalOrHonor(pairTile);
if (allHaveTermHonor) {
const hasSequence = allGroups.some((g) => g.type === "sequence");
const hasHonorGroup = allGroups.some((g) => g.type === "triplet" && isHonor(g.tile)) || isHonor(pairTile);
if (hasSequence) { if (hasHonorGroup) yaku.push({ name: "混全帯幺九", han: isMenzen ? 2 : 1 }); else yaku.push({ name: "純全帯幺九", han: isMenzen ? 3 : 2 }); }
}
}
const allTriplet = allGroups.every((g) => g.type === "triplet");
if (allTriplet) yaku.push({ name: "対々和", han: 2 });
{
let ankouCount = 0;
for (let i = 0; i < allGroups.length; i++) { const g = allGroups[i]; if (g.type !== "triplet" || g.open) continue; if (i === winGroupIdx && !isTsumo) continue; ankouCount++; }
if (ankouCount === 4) { yakumanCount += 1; yaku.push({ name: "四暗刻", han: 13, yakuman: true }); }
else if (ankouCount === 3) yaku.push({ name: "三暗刻", han: 2 });
}
const allTermHonorTiles = allGroups.every((g) => g.type !== "sequence" && isTerminalOrHonor(g.tile)) && isTerminalOrHonor(pairTile);
if (allTermHonorTiles && allTriplet) yaku.push({ name: "混老頭", han: 2 });
if (allTriplet && allGroups.every((g) => isTerminal(g.tile)) && isTerminal(pairTile)) { yakumanCount += 1; yaku.push({ name: "清老頭", han: 13, yakuman: true }); }
{
const tripNums = {};
for (const g of allGroups) { if (g.type !== "triplet" || isHonor(g.tile)) continue; const num = g.tile % 9; tripNums[num] = tripNums[num] || new Set(); tripNums[num].add(Core.tileSuit(g.tile)); }
let found = false; for (const num in tripNums) if (tripNums[num].size === 3) found = true;
if (found) yaku.push({ name: "三色同刻", han: 2 });
}
{
let dragonTriplets = 0; for (const g of allGroups) if (g.type === "triplet" && DRAGONS.indexOf(g.tile) !== -1) dragonTriplets++;
const dragonPair = DRAGONS.indexOf(pairTile) !== -1;
if (dragonTriplets === 3) { yakumanCount += 1; yaku.push({ name: "大三元", han: 13, yakuman: true }); }
else if (dragonTriplets === 2 && dragonPair) yaku.push({ name: "小三元", han: 2 });
}
{
let windTriplets = 0; for (const g of allGroups) if (g.type === "triplet" && g.tile >= 27 && g.tile <= 30) windTriplets++;
const windPair = pairTile >= 27 && pairTile <= 30;
if (windTriplets === 4) { yakumanCount += 1; yaku.push({ name: "大四喜", han: 13, yakuman: true }); }
else if (windTriplets === 3 && windPair) { yakumanCount += 1; yaku.push({ name: "小四喜", han: 13, yakuman: true }); }
}
{
const suits = new Set(); let hasHonor = false;
for (const g of allGroups) { if (g.type === "sequence") suits.add(Core.tileSuit(g.tile)); else if (isHonor(g.tile)) hasHonor = true; else suits.add(Core.tileSuit(g.tile)); }
if (isHonor(pairTile)) hasHonor = true; else suits.add(Core.tileSuit(pairTile));
if (suits.size === 1) { if (hasHonor) yaku.push({ name: "混一色", han: isMenzen ? 3 : 2 }); else yaku.push({ name: "清一色", han: isMenzen ? 6 : 5 }); }
}
if (allGroups.every((g) => isHonor(g.tile)) && isHonor(pairTile)) { yakumanCount += 1; yaku.push({ name: "字一色", han: 13, yakuman: true }); }
{
const greenOk = allGroups.every((g) => g.type === "sequence" ? (Core.isGreenTile(g.tile) && Core.isGreenTile(g.tile + 1) && Core.isGreenTile(g.tile + 2)) : Core.isGreenTile(g.tile)) && Core.isGreenTile(pairTile);
if (greenOk) { yakumanCount += 1; yaku.push({ name: "緑一色", han: 13, yakuman: true }); }
}
if (ctx.isChuuren) { yakumanCount += 1; yaku.push({ name: "九蓮宝燈", han: 13, yakuman: true }); }
if (isDoubleRiichi) yaku.push({ name: "ダブル立直", han: 2 }); else if (isRiichi) yaku.push({ name: "立直", han: 1 });
if (isIppatsu) yaku.push({ name: "一発", han: 1 });
if (isMenzen && isTsumo) yaku.push({ name: "門前清自摸和", han: 1 });
if (isRinshan) yaku.push({ name: "嶺上開花", han: 1 });
if (isChankan) yaku.push({ name: "槍槓", han: 1 });
if (isHaitei && isTsumo) yaku.push({ name: "海底摸月", han: 1 });
if (isHoutei && !isTsumo) yaku.push({ name: "河底撈魚", han: 1 });
if (ctx.isTenhou) { yakumanCount += 1; yaku.push({ name: "天和", han: 13, yakuman: true }); }
if (ctx.isChiihou) { yakumanCount += 1; yaku.push({ name: "地和", han: 13, yakuman: true }); }
let fu = 20; const fuDetail = ["副底20"];
if (isMenzen && !isTsumo) { fu += 10; fuDetail.push("門前ロン+10"); }
for (let i = 0; i < allGroups.length; i++) {
const g = allGroups[i]; if (g.type !== "triplet") continue;
const term = isTerminalOrHonor(g.tile);
let f = g.kan ? (term ? 32 : 16) : (term ? 8 : 4);
let open = g.open;
if (i === winGroupIdx && !isTsumo && !g.open) open = true;
if (open) f = f / 2;
fu += f; fuDetail.push((g.kan ? "槓子" : "刻子") + f);
}
const pairFu = (DRAGONS.indexOf(pairTile) !== -1 ? 2 : 0) + (pairTile === windTileFor(seatWindIdx) ? 2 : 0) + (pairTile === windTileFor(roundWindIdx) ? 2 : 0);
if (pairFu > 0) { fu += pairFu; fuDetail.push("雀頭役牌+" + pairFu); }
if (waitType === "kanchan" || waitType === "penchan" || waitType === "tanki") { fu += 2; fuDetail.push("待ち+2"); }
if (isTsumo && !isPinfu) { fu += 2; fuDetail.push("自摸+2"); }
let finalFu = (isPinfu && isTsumo) ? 20 : Math.ceil(fu / 10) * 10;
let han = 0; for (const y of yaku) han += y.han;
let doraHan = 0;
const allTiles14 = allGroups.reduce((arr, g) => {
if (g.type === "sequence") arr.push(g.tile, g.tile + 1, g.tile + 2);
else { const n = g.kan ? 4 : 3; for (let k = 0; k < n; k++) arr.push(g.tile); }
return arr;
}, [pairTile, pairTile]);
for (const ind of doraIndicators || []) { const dt = nextDoraTile(ind); for (const t of allTiles14) if (t === dt) doraHan++; }
if (isRiichi && uraDoraIndicators) for (const ind of uraDoraIndicators) { const dt = nextDoraTile(ind); for (const t of allTiles14) if (t === dt) doraHan++; }
doraHan += akaCount || 0;
return { yaku, han, yakumanCount, fu: finalFu, fuDetail, doraHan, waitType, totalHan: han + doraHan };
}
function nextDoraTile(indicator) {
if (indicator >= 27) { if (indicator <= 30) return 27 + ((indicator - 27 + 1) % 4); return 31 + ((indicator - 31 + 1) % 3); }
const suitBase = indicator - (indicator % 9); const num = indicator % 9;
return suitBase + ((num + 1) % 9);
}
function checkChuuren(counts, winTile) {
for (const base of [0, 9, 18]) {
const suitCounts = counts.slice(base, base + 9);
const total = suitCounts.reduce((a, b) => a + b, 0);
if (total !== 14) continue;
const otherSuits = counts.slice(0, base).concat(counts.slice(base + 9));
if (otherSuits.some((c) => c > 0)) continue;
const need = [3, 1, 1, 1, 1, 1, 1, 1, 3]; let ok = true;
for (let i = 0; i < 9; i++) if (suitCounts[i] < need[i]) { ok = false; break; }
if (ok && Core.tileSuit(winTile) === Core.tileSuit(base)) return true;
}
return false;
}
function evaluateWin(input) {
const { concealedTiles, melds, winTile, isTsumo } = input;
const counts = Core.countsFromArray(concealedTiles);
const meldGroups = (melds || []).map(meldToGroup);
const isMenzen = (melds || []).every((m) => m.type === "ankan");
const results = [];
const decomps = allHandDecompositions(counts, (melds || []).length);
for (const d of decomps) { const allGroups = d.groups.concat(meldGroups); const ctx = Object.assign({}, input, { isMenzen }); results.push(evaluateDecomp(ctx, allGroups, d.pair)); }
if (!melds || melds.length === 0) {
if (Core.isChiitoiComplete(counts)) {
const ctx = Object.assign({}, input, { isMenzen: true });
const yaku = [{ name: "七対子", han: 2 }];
if (ctx.isDoubleRiichi) yaku.push({ name: "ダブル立直", han: 2 }); else if (ctx.isRiichi) yaku.push({ name: "立直", han: 1 });
if (ctx.isIppatsu) yaku.push({ name: "一発", han: 1 });
if (ctx.isTsumo) yaku.push({ name: "門前清自摸和", han: 1 });
if (ctx.isRinshan) yaku.push({ name: "嶺上開花", han: 1 });
if (ctx.isChankan) yaku.push({ name: "槍槓", han: 1 });
if (ctx.isHaitei && ctx.isTsumo) yaku.push({ name: "海底摸月", han: 1 });
if (ctx.isHoutei && !ctx.isTsumo) yaku.push({ name: "河底撈魚", han: 1 });
if (ctx.isTenhou) yaku.push({ name: "天和", han: 13, yakuman: true });
if (ctx.isChiihou) yaku.push({ name: "地和", han: 13, yakuman: true });
let allSimple = true, suitSet = new Set(), hasHonor = false, allTermHonor = true;
for (let t = 0; t < 34; t++) if (counts[t] > 0) { if (isTerminalOrHonor(t)) allSimple = false; else allTermHonor = false; if (isHonor(t)) hasHonor = true; else suitSet.add(Core.tileSuit(t)); }
if (allSimple) yaku.push({ name: "断幺九", han: 1 });
if (suitSet.size === 1 && !hasHonor) yaku.push({ name: "清一色", han: 6 }); else if (suitSet.size <= 1 && hasHonor) yaku.push({ name: "混一色", han: 3 });
if (allTermHonor) yaku.push({ name: "混老頭", han: 2 });
let han = 0, yakumanCount = 0; for (const y of yaku) { han += y.han; if (y.yakuman) yakumanCount++; }
let doraHan = 0;
for (const ind of ctx.doraIndicators || []) { const dt = nextDoraTile(ind); doraHan += counts[dt] || 0; }
if (ctx.isRiichi) for (const ind of ctx.uraDoraIndicators || []) { const dt = nextDoraTile(ind); doraHan += counts[dt] || 0; }
doraHan += ctx.akaCount || 0;
results.push({ yaku, han, yakumanCount, fu: 25, fuDetail: ["七対子固定25符"], doraHan, waitType: "chiitoi", totalHan: han + doraHan });
}
if (Core.isKokushiComplete(counts)) results.push({ yaku: [{ name: "国士無双", han: 13, yakuman: true }], han: 13, yakumanCount: 1, fu: 25, fuDetail: ["役満"], doraHan: 0, waitType: "kokushi", totalHan: 13 });
}
if ((!melds || melds.length === 0) && checkChuuren(counts, winTile)) {
for (const d of decomps) { const allGroups = d.groups.concat(meldGroups); const ctx = Object.assign({}, input, { isMenzen, isChuuren: true }); results.push(evaluateDecomp(ctx, allGroups, d.pair)); }
}
if (results.length === 0) return null;
const valid = results.filter((r) => r.han > 0 || r.yakumanCount > 0);
if (valid.length === 0) return null;
valid.sort((a, b) => { const av = a.yakumanCount > 0 ? 999999 : scoreRank(a); const bv = b.yakumanCount > 0 ? 999999 : scoreRank(b); return bv - av; });
return valid[0];
}
function scoreRank(r) { return r.totalHan * 1000 + r.fu; }
function calcScore(result, isDealer) {
const han = result.totalHan; const yakumanCount = result.yakumanCount || 0;
let basePoints, label = "";
if (yakumanCount > 0) { basePoints = 8000 * yakumanCount; label = yakumanCount > 1 ? `${yakumanCount}倍役満` : "役満"; }
else if (han >= 11) { basePoints = 6000; label = "三倍満"; }
else if (han >= 8) { basePoints = 4000; label = "倍満"; }
else if (han >= 6) { basePoints = 3000; label = "跳満"; }
else if (han >= 5) { basePoints = 2000; label = "満貫"; }
else { basePoints = result.fu * Math.pow(2, 2 + han); if (basePoints > 2000) { basePoints = 2000; label = "満貫"; } }
const ru = (x) => Math.ceil(x / 100) * 100;
let payments;
if (isDealer) { const each = ru(basePoints * 2); payments = { total: each * 3, detail: [each, each, each] }; }
else { const fromDealer = ru(basePoints * 2); const fromOthers = ru(basePoints * 1); payments = { total: fromDealer + fromOthers * 2, detail: [fromDealer, fromOthers, fromOthers] }; }
const ronTotal = isDealer ? ru(basePoints * 6) : ru(basePoints * 4);
return { basePoints, label, tsumoPayments: payments, ronTotal };
}
return { allHandDecompositions, evaluateDecomp, evaluateWin, calcScore, meldToGroup, nextDoraTile, checkChuuren };
})(window.MJCore);
/* ===== game.js ===== */
/* ==================== game.js ==================== */
window.MJGame = (function (Core, Yaku) {
"use strict";
const RED_IDS = new Set([16, 52, 88]);
function tileType(id) { return Math.floor(id / 4); }
function isRedId(id) { return RED_IDS.has(id); }
function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; }
function buildWall() { const ids = []; for (let i = 0; i < 136; i++) ids.push(i); shuffle(ids); return ids; }
const WIND_NAMES = ["東", "南", "西", "北"];
class MahjongGame {
constructor(opts) {
this.opts = opts || {};
this.players = [0, 1, 2, 3].map((i) => ({ seat: i, score: 25000, hand: [], melds: [], discards: [], riichi: false, doubleRiichi: false, riichiDiscardIndex: -1, ippatsuEligible: false, isHuman: i === 0, character: null, tempFuriten: false, permFuriten: false }));
this.roundWindIdx = 0; this.roundNumber = 1; this.honba = 0; this.riichiSticks = 0; this.dealerSeat = Math.floor(Math.random() * 4); this.gameOver = false; this.log = []; this.uiEvents = [];
}
seatWindIdx(seat) { return (seat - this.dealerSeat + 4) % 4; }
startHand() {
this.wall = buildWall();
this.deadWall = this.wall.splice(this.wall.length - 14, 14);
this.rinshanUsed = 0; this.doraRevealed = 1; this.kanCount = 0;
for (const p of this.players) { p.hand = []; p.melds = []; p.discards = []; p.riichi = false; p.doubleRiichi = false; p.riichiDiscardIndex = -1; p.ippatsuEligible = false; p.tempFuriten = false; p.permFuriten = false; }
for (let round = 0; round < 13; round++) for (let s = 0; s < 4; s++) this.players[s].hand.push(this.wall.shift());
for (const p of this.players) p.hand.sort((a, b) => tileType(a) - tileType(b));
this.turn = this.dealerSeat; this.turnCount = 0; this.anyCallHappened = false; this.lastDiscard = null; this.log = []; this.uiEvents = []; this.handOver = false;
}
doraIndicatorTiles() { const res = []; for (let i = 0; i < this.doraRevealed; i++) res.push(tileType(this.deadWall[4 + i])); return res; }
uraDoraIndicatorTiles() { const res = []; for (let i = 0; i < this.doraRevealed; i++) res.push(tileType(this.deadWall[9 + i])); return res; }
remainingLiveWall() { return this.wall.length; }
drawTile(seat, fromRinshan) {
let id;
if (fromRinshan) { id = this.deadWall[this.rinshanUsed]; this.rinshanUsed++; } else { id = this.wall.shift(); }
this.players[seat].hand.push(id); return id;
}
handConcealedTypes(seat) { return this.players[seat].hand.map(tileType); }
countAka(seat, extraId) {
let n = 0;
for (const id of this.players[seat].hand) if (isRedId(id)) n++;
for (const m of this.players[seat].melds) for (const id of m.idTiles || []) if (isRedId(id)) n++;
if (extraId !== undefined && isRedId(extraId)) n++;
return n;
}
tryWin(seat, winTile, isTsumo, situational) {
const p = this.players[seat];
const concealedTypes = p.hand.map(tileType);
const melds = p.melds.map((m) => ({ type: m.type, tiles: m.tiles }));
const ctx = Object.assign({
concealedTiles: concealedTypes, melds, winTile: tileType(winTile), isTsumo,
isMenzen: p.melds.every((m) => m.type === "ankan"), isRiichi: p.riichi, isDoubleRiichi: p.doubleRiichi,
isIppatsu: !!p.ippatsuEligible, seatWindIdx: this.seatWindIdx(seat), roundWindIdx: this.roundWindIdx,
doraIndicators: this.doraIndicatorTiles(), uraDoraIndicators: this.uraDoraIndicatorTiles(),
akaCount: this.countAka(seat, isTsumo ? undefined : winTile),
}, situational || {});
return Yaku.evaluateWin(ctx);
}
shantenInfo(seat) {
const counts = Core.countsFromArray(this.handConcealedTypes(seat));
const meldCount = this.players[seat].melds.length;
const shanten = Core.calcShanten(counts, meldCount);
const uke = shanten >= 0 ? Core.ukeire(counts, meldCount) : [];
return { shanten, ukeire: uke, counts };
}
isTenpai(seat) { return this.shantenInfo(seat).shanten === 0; }
waitingTiles(seat) {
const info = this.shantenInfo(seat); if (info.shanten !== 0) return [];
const waits = [];
for (let t = 0; t < 34; t++) { const c2 = info.counts.slice(); if (c2[t] >= 4) continue; c2[t]++; if (Core.isAgari(c2, this.players[seat].melds.length)) waits.push(t); }
return waits;
}
updateFuriten(seat) {
const p = this.players[seat]; const waits = this.waitingTiles(seat);
if (waits.length === 0) { p.permFuriten = false; return; }
const discardedTypes = p.discards.map((d) => tileType(d.tile));
p.permFuriten = waits.some((w) => discardedTypes.indexOf(w) !== -1);
}
}
return { MahjongGame, tileType, isRedId, buildWall, WIND_NAMES, shuffle };
})(window.MJCore, window.MJYaku);
/* ===== cpu.js ===== */
/* ==================== cpu.js ==================== */
window.MJCpu = (function (Core) {
"use strict";
const IMG_BASE = "https://attachstyle.com/wp-content/uploads/2026/09/";
const CHARACTERS = [
{ id: "kon", name: "コン", animal: "狐", emoji: "🦊", portrait: IMG_BASE + "Mahjong_CPU01.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU01-2.jpeg", pos: "58% 32%", catch: "攻撃的な鳴き師。速度最優先で愚形でも鳴いて聴牌を急ぐ。", aggression: .85, defense: .25, callThreshold: .25, riichiThreshold: .35, bigHandChase: .15, randomness: .1 },
{ id: "mai", name: "マイ", animal: "鶴", emoji: "🕊️", portrait: IMG_BASE + "Mahjong_CPU02.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU02-2.jpeg", pos: "62% 18%", catch: "堅実な面前派。鳴かずにじっくり高い手を育てる。", aggression: .35, defense: .6, callThreshold: .85, riichiThreshold: .75, bigHandChase: .65, randomness: .05 },
{ id: "gou", name: "ゴウ", animal: "虎", emoji: "🐯", portrait: IMG_BASE + "Mahjong_CPU03.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU03-2.jpeg", pos: "55% 20%", catch: "リーチ全ツッパ型。聴牌したら即リーチ、押し一択。", aggression: .9, defense: .05, callThreshold: .5, riichiThreshold: .05, bigHandChase: .3, randomness: .1 },
{ id: "pyon", name: "ピョン", animal: "兎", emoji: "🐰", portrait: IMG_BASE + "Mahjong_CPU04.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU04-2.jpeg", pos: "52% 32%", catch: "超守備的で慌てやすい。危険な気配を感じたらすぐベタオリ。", aggression: .2, defense: .95, callThreshold: .7, riichiThreshold: .6, bigHandChase: .1, randomness: .1 },
{ id: "fuku", name: "フク", animal: "梟", emoji: "🦉", portrait: IMG_BASE + "Mahjong_CPU05.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU05-2.jpeg", pos: "58% 34%", catch: "知略型。場況を見て攻守を柔軟に切り替える。", aggression: .55, defense: .55, callThreshold: .5, riichiThreshold: .45, bigHandChase: .4, randomness: .1 },
{ id: "ran", name: "ラン", animal: "猫", emoji: "🐱", portrait: IMG_BASE + "Mahjong_CPU06.jpeg", winPortrait: IMG_BASE + "Mahjong_CPU06-2.jpeg", pos: "56% 32%", catch: "気まぐれ型。同じ場面でも対局ごとに判断がブレる。", aggression: .5, defense: .5, callThreshold: .5, riichiThreshold: .5, bigHandChase: .3, randomness: .4 },
];
const PLAYER_AVATARS = {
male: { label: "男性", portrait: IMG_BASE + "Mahjong_USER01.jpeg", winPortrait: IMG_BASE + "Mahjong_USER01-2.jpeg", pos: "55% 22%" },
female: { label: "女性", portrait: IMG_BASE + "Mahjong_USER02.jpeg", winPortrait: IMG_BASE + "Mahjong_USER02-2.jpeg", pos: "50% 20%" },
};
function pickThree() {
const pool = CHARACTERS.slice();
for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; }
return pool.slice(0, 3);
}
function jitter(persona, base) { const spread = 0.15 + persona.randomness * 0.5; return Math.max(0, Math.min(1, base + (Math.random() * 2 - 1) * spread)); }
function effectiveUkeireCount(counts, meldCount, visibleCounts) {
const shanten = Core.calcShanten(counts, meldCount); let total = 0;
for (let t = 0; t < 34; t++) { if (counts[t] >= 4) continue; const c2 = counts.slice(); c2[t]++; if (Core.calcShanten(c2, meldCount) < shanten) { const remain = 4 - counts[t] - (visibleCounts[t] || 0); total += Math.max(0, remain); } }
return total;
}
function safeSetAgainst(game, targetSeat) {
const p = game.players[targetSeat];
const safe = new Set(p.discards.map((d) => tileTypeOf(d.tile)));
if (p.riichi) { for (const other of game.players) { if (other.seat === targetSeat) continue; for (let i = 0; i < other.discards.length; i++) if (other.discards[i].turnMark >= (p.riichiTurnMark || 0)) safe.add(tileTypeOf(other.discards[i].tile)); } }
return safe;
}
function tileTypeOf(id) { return Math.floor(id / 4); }
function anyoneRiichi(game, exceptSeat) { return game.players.some((p) => p.seat !== exceptSeat && p.riichi); }
function chooseDiscard(game, seat, persona) {
const p = game.players[seat]; const handIds = p.hand; const meldCount = p.melds.length;
const baseCounts = Core.countsFromArray(handIds.map(tileTypeOf));
const dangerActive = anyoneRiichi(game, seat) && Core.calcShanten(baseCounts, meldCount) > 0;
let safeUnion = new Set();
if (dangerActive) for (const other of game.players) if (other.seat !== seat && other.riichi) for (const t of safeSetAgainst(game, other.seat)) safeUnion.add(t);
const wantDefense = dangerActive && jitter(persona, persona.defense) > 0.45;
const uniqueTypes = Array.from(new Set(handIds.map(tileTypeOf)));
let candidates = uniqueTypes.map((t) => {
const c2 = baseCounts.slice(); c2[t]--;
const shanten = Core.calcShanten(c2, meldCount);
const ukeire = shanten >= 0 ? effectiveUkeireCount(c2, meldCount, Core.newCounts()) : 0;
return { type: t, shanten, ukeire, isSafe: safeUnion.has(t) };
});
if (wantDefense) {
const safeCands = candidates.filter((c) => c.isSafe);
if (safeCands.length > 0) { const pick = safeCands[Math.floor(Math.random() * safeCands.length)]; return pickIdOfType(handIds, pick.type); }
}
candidates.sort((a, b) => a.shanten !== b.shanten ? a.shanten - b.shanten : b.ukeire - a.ukeire);
const bestShanten = candidates[0].shanten;
let pool = candidates.filter((c) => c.shanten === bestShanten);
const topUke = pool[0] ? pool.reduce((m, c) => Math.max(m, c.ukeire), 0) : 0;
pool = pool.filter((c) => c.ukeire >= topUke - Math.floor(persona.randomness * 3));
const pick = pool[Math.floor(Math.random() * pool.length)] || candidates[0];
return pickIdOfType(handIds, pick.type);
}
function pickIdOfType(handIds, type) { for (const id of handIds) if (tileTypeOf(id) === type) return id; return handIds[0]; }
function decideRiichi(game, seat, persona) { const roll = jitter(persona, 1 - persona.riichiThreshold); return roll > 0.4; }
function decideCall(game, seat, persona, kind, resultingShantenDelta) {
if (persona.callThreshold >= 0.8) return false;
const willingness = jitter(persona, 1 - persona.callThreshold);
if (resultingShantenDelta > 0) return willingness > 0.3;
return willingness > 0.7;
}
function decidePushFold(game, seat, persona) { return jitter(persona, persona.aggression) > 0.35; }
return { CHARACTERS, PLAYER_AVATARS, pickThree, chooseDiscard, decideRiichi, decideCall, decidePushFold, tileTypeOf };
})(window.MJCore);
/* ===== controller.js ===== */
/* ==================== controller.js ==================== */
window.MJController = (function (Core, Yaku, G, Cpu) {
"use strict";
const tileTypeOf = G.tileType;
function removeOneOfType(handIds, type) { for (let i = 0; i < handIds.length; i++) if (tileTypeOf(handIds[i]) === type) return handIds.splice(i, 1)[0]; return null; }
function canChi(counts, discardType) {
if (discardType >= 27) return [];
const pos = discardType % 9; const opts = [];
if (pos <= 6 && counts[discardType + 1] > 0 && counts[discardType + 2] > 0) opts.push([discardType, discardType + 1, discardType + 2]);
if (pos >= 1 && pos <= 7 && counts[discardType - 1] > 0 && counts[discardType + 1] > 0) opts.push([discardType - 1, discardType, discardType + 1]);
if (pos >= 2 && counts[discardType - 2] > 0 && counts[discardType - 1] > 0) opts.push([discardType - 2, discardType - 1, discardType]);
return opts;
}
function* playHand(game, personas) {
game.startHand();
for (let s = 0; s < 4; s++) game.players[s].character = personas[s] || null;
let ippatsuChance = [false, false, false, false];
let hasDrawnOnce = [false, false, false, false];
let turnMark = 0;
game.turn = game.dealerSeat;
let handResult = null;
outer:
while (true) {
const seat = game.turn; const p = game.players[seat];
if (game.remainingLiveWall() <= 0) { handResult = resolveExhaustiveDraw(game); break; }
const drawnId = game.drawTile(seat, false);
const isFirstDrawEver = !hasDrawnOnce[seat] && !game.anyCallHappened;
hasDrawnOnce[seat] = true; turnMark++;
const tsumoResult = game.tryWin(seat, drawnId, true, { isIppatsu: ippatsuChance[seat], isHaitei: game.remainingLiveWall() === 0, isTenhou: isFirstDrawEver && seat === game.dealerSeat, isChiihou: isFirstDrawEver && seat !== game.dealerSeat });
let doTsumo = false;
if (tsumoResult) {
if (p.character) doTsumo = true;
else { const resp = yield { phase: "tsumo-choice", seat, tile: drawnId, result: tsumoResult }; doTsumo = !!(resp && resp.action === "tsumo"); }
}
if (doTsumo) { yield { phase: "action-log", seat, kind: "tsumo" }; handResult = finishWin(game, [{ seat, winTile: drawnId, isTsumo: true, result: tsumoResult }], null); break; }
ippatsuChance[seat] = false;
const kyuushuEligible = isFirstDrawEver && p.melds.length === 0 && countYaochuuKinds(p.hand) >= 9;
if (kyuushuEligible) {
let doAbort = false;
if (p.character) doAbort = true;
else { const resp = yield { phase: "kyuushu-choice", seat }; doAbort = !!(resp && resp.action === "kyuushu"); }
if (doAbort) { handResult = { abortive: true, reason: "九種九牌", dealerSeat: game.dealerSeat, dealerContinues: true }; break; }
}
const canRiichi = p.melds.every((m) => m.type === "ankan") && !p.riichi && p.score >= 1000 && game.remainingLiveWall() >= 4 && game.shantenInfo(seat).shanten === 0;
let discardId, declareRiichi = false;
if (p.character) {
if (canRiichi && Cpu.decideRiichi(game, seat, p.character)) declareRiichi = true;
discardId = Cpu.chooseDiscard(game, seat, p.character);
} else if (p.riichi) {
// リーチ後は手牌が固定されるため、選択肢を出さずツモった牌をそのまま切る(ツモ切り)
discardId = drawnId;
} else {
const resp = yield { phase: "discard-choice", seat, tile: drawnId, canRiichi };
discardId = (resp && resp.tile != null) ? resp.tile : drawnId;
declareRiichi = !!(resp && resp.declareRiichi && canRiichi);
}
const isPlayersFirstDiscard = p.discards.length === 0;
removeOneOfType(p.hand, tileTypeOf(discardId));
p.hand.sort((a, b) => tileTypeOf(a) - tileTypeOf(b));
if (declareRiichi) {
p.riichi = true; p.doubleRiichi = isPlayersFirstDiscard && !game.anyCallHappened && p.melds.length === 0;
p.score -= 1000; game.riichiSticks += 1; p.riichiTurnMark = turnMark; ippatsuChance[seat] = true;
}
p.discards.push({ tile: discardId, turnMark, riichiTile: declareRiichi });
game.lastDiscard = { seat, tile: discardId };
if (declareRiichi) { game.uiEvents.push({ type: "riichi", seat }); yield { phase: "action-log", seat, kind: "riichi" }; }
game.uiEvents.push({ type: "discard", seat, tile: discardId });
if (p.character && !declareRiichi) { yield { phase: "action-log", seat, kind: "discard" }; }
for (const pl of game.players) game.updateFuriten(pl.seat);
const ronSeats = [];
for (let d = 1; d <= 3; d++) {
const other = (seat + d) % 4; const op = game.players[other];
if (op.permFuriten || op.tempFuriten) continue;
const r = game.tryWin(other, discardId, false, { isIppatsu: ippatsuChance[other], isHoutei: game.remainingLiveWall() === 0 });
if (r) {
let accept = true;
if (!op.character) { const resp = yield { phase: "ron-choice", seat: other, tile: discardId, result: r }; accept = !!(resp && resp.action === "ron"); }
if (accept) ronSeats.push({ seat: other, winTile: discardId, isTsumo: false, result: r }); else op.tempFuriten = true;
}
}
if (ronSeats.length === 3) { handResult = { abortive: true, reason: "三家和", dealerSeat: game.dealerSeat, dealerContinues: game.dealerSeat === seat }; break; }
if (ronSeats.length > 0) {
for (const rs of ronSeats) game.players[rs.seat].tempFuriten = false;
for (const rs of ronSeats) yield { phase: "action-log", seat: rs.seat, kind: "ron" };
handResult = finishWin(game, ronSeats, seat); break;
}
let callDone = false;
for (let d = 1; d <= 3 && !callDone; d++) {
const other = (seat + d) % 4; const op = game.players[other];
const cnt = op.hand.filter((id) => tileTypeOf(id) === tileTypeOf(discardId)).length;
if (cnt >= 2 && !op.riichi) {
const beforeShanten = game.shantenInfo(other).shanten;
const simHand = op.hand.slice();
removeOneOfType(simHand, tileTypeOf(discardId)); removeOneOfType(simHand, tileTypeOf(discardId));
const afterCounts = Core.countsFromArray(simHand.map(tileTypeOf));
const afterShanten = Core.calcShanten(afterCounts, op.melds.length + 1);
let accept = false;
if (op.character) accept = Cpu.decideCall(game, other, op.character, "pon", beforeShanten - afterShanten);
else { const resp = yield { phase: "pon-choice", seat: other, tile: discardId }; accept = !!(resp && resp.action === "pon"); }
if (accept) {
removeOneOfType(op.hand, tileTypeOf(discardId)); removeOneOfType(op.hand, tileTypeOf(discardId));
op.melds.push({ type: "pon", tiles: [tileTypeOf(discardId), tileTypeOf(discardId), tileTypeOf(discardId)], idTiles: [discardId], fromSeat: seat });
game.uiEvents.push({ type: "meld", seat: other, meldType: "pon" });
yield { phase: "action-log", seat: other, kind: "pon" };
game.anyCallHappened = true; ippatsuChance = [false, false, false, false];
game.turn = other; callDone = true;
const postDiscard = yield* handlePostCallDiscard(game, other, turnMark);
game.lastDiscard = { seat: other, tile: postDiscard };
for (const pl of game.players) game.updateFuriten(pl.seat);
const ronSeats2 = yield* checkRonAfter(game, other, postDiscard, ippatsuChance);
if (ronSeats2 && ronSeats2.length) { handResult = finishWin(game, ronSeats2, other); break outer; }
}
}
}
if (callDone) { game.turn = (game.turn + 1) % 4; game.turnCount++; continue; }
{
const other = (seat + 1) % 4; const op = game.players[other];
const counts = Core.countsFromArray(op.hand.map(tileTypeOf));
const opts = canChi(counts, tileTypeOf(discardId));
if (opts.length > 0 && !op.riichi) {
const beforeShanten = game.shantenInfo(other).shanten;
const chosen = opts[0];
const simHand = op.hand.slice();
for (const t of chosen) if (t !== tileTypeOf(discardId)) removeOneOfType(simHand, t);
const afterCounts = Core.countsFromArray(simHand.map(tileTypeOf));
const afterShanten = Core.calcShanten(afterCounts, op.melds.length + 1);
let accept = false;
if (op.character) accept = Cpu.decideCall(game, other, op.character, "chi", beforeShanten - afterShanten);
else { const resp = yield { phase: "chi-choice", seat: other, tile: discardId, options: opts }; accept = !!(resp && resp.action === "chi"); }
if (accept) {
for (const t of chosen) if (t !== tileTypeOf(discardId)) removeOneOfType(op.hand, t);
op.melds.push({ type: "chi", tiles: chosen, idTiles: [discardId], fromSeat: seat });
game.uiEvents.push({ type: "meld", seat: other, meldType: "chi" });
yield { phase: "action-log", seat: other, kind: "chi" };
game.anyCallHappened = true; ippatsuChance = [false, false, false, false];
game.turn = other;
const postDiscard = yield* handlePostCallDiscard(game, other, turnMark);
game.lastDiscard = { seat: other, tile: postDiscard };
for (const pl of game.players) game.updateFuriten(pl.seat);
const ronSeats2 = yield* checkRonAfter(game, other, postDiscard, ippatsuChance);
if (ronSeats2 && ronSeats2.length) { handResult = finishWin(game, ronSeats2, other); break; }
game.turn = (other + 1) % 4; game.turnCount++;
continue outer;
}
}
}
game.turn = (seat + 1) % 4; game.turnCount++;
}
return applyHandResult(game, handResult);
}
function* handlePostCallDiscard(game, seat, turnMark) {
const p = game.players[seat]; let discardId;
if (p.character) discardId = Cpu.chooseDiscard(game, seat, p.character);
else { const resp = yield { phase: "discard-choice", seat, tile: null, canRiichi: false }; discardId = (resp && resp.tile != null) ? resp.tile : p.hand[0]; }
removeOneOfType(p.hand, tileTypeOf(discardId));
p.hand.sort((a, b) => tileTypeOf(a) - tileTypeOf(b));
p.discards.push({ tile: discardId, turnMark, riichiTile: false });
game.uiEvents.push({ type: "discard", seat, tile: discardId });
game.lastDiscard = { seat, tile: discardId };
if (p.character) { yield { phase: "action-log", seat, kind: "discard" }; }
return discardId;
}
function* checkRonAfter(game, seat, discardId, ippatsuChance) {
const ronSeats = [];
for (let d = 1; d <= 3; d++) {
const other = (seat + d) % 4; const op = game.players[other];
if (op.permFuriten || op.tempFuriten) continue;
const r = game.tryWin(other, discardId, false, { isIppatsu: ippatsuChance[other], isHoutei: game.remainingLiveWall() === 0 });
if (r) {
let accept = true;
if (!op.character) { const resp = yield { phase: "ron-choice", seat: other, tile: discardId, result: r }; accept = !!(resp && resp.action === "ron"); }
if (accept) { ronSeats.push({ seat: other, winTile: discardId, isTsumo: false, result: r }); yield { phase: "action-log", seat: other, kind: "ron" }; }
else op.tempFuriten = true;
}
}
return ronSeats;
}
function countYaochuuKinds(handIds) { const seen = new Set(); for (const id of handIds) { const t = tileTypeOf(id); if (Core.YAOCHUU.indexOf(t) !== -1) seen.add(t); } return seen.size; }
function finishWin(game, winners, discarderSeat) { return { winners, discarderSeat, abortive: false }; }
function resolveExhaustiveDraw(game) {
const tenpaiSeats = []; for (const p of game.players) if (game.isTenpai(p.seat)) tenpaiSeats.push(p.seat);
return { exhaustive: true, tenpaiSeats, dealerContinues: tenpaiSeats.indexOf(game.dealerSeat) !== -1 };
}
function applyHandResult(game, handResult) {
const dealerBefore = game.dealerSeat;
const beforeScores = game.players.map((p) => p.score);
const summary = { players: game.players.map((p) => ({ seat: p.seat, score: p.score })), events: [] };
const finalizeSummary = (reasons) => {
summary.dealerBefore = dealerBefore;
summary.dealerAfter = game.dealerSeat;
summary.dealerChanged = dealerBefore !== game.dealerSeat;
summary.scoreChanges = game.players.map((p, i) => ({ seat: p.seat, before: beforeScores[i], after: p.score, delta: p.score - beforeScores[i], reason: (reasons && reasons[p.seat]) || "" }));
};
if (handResult.abortive) {
summary.type = "abortive"; summary.reason = handResult.reason;
if (handResult.dealerContinues) game.honba++; else game.honba = 0;
advanceDealer(game, handResult.dealerContinues);
const reasons = {};
for (const p of game.players) reasons[p.seat] = `途中流局(${handResult.reason})`;
finalizeSummary(reasons);
return summary;
}
if (handResult.exhaustive) {
summary.type = "exhaustive"; summary.tenpaiSeats = handResult.tenpaiSeats;
const T = handResult.tenpaiSeats.length;
const table = { 0: [0, 0], 1: [3000, 1000], 2: [1500, 1500], 3: [1000, 3000], 4: [0, 0] };
const [gain, lose] = table[T];
const reasons = {};
for (const p of game.players) {
const isTenpai = handResult.tenpaiSeats.indexOf(p.seat) !== -1;
if (isTenpai) p.score += gain; else p.score -= lose;
reasons[p.seat] = (T === 0 || T === 4) ? `流局(全員${T === 4 ? "聴牌" : "不聴"})` : (isTenpai ? "聴牌料" : "不聴罰符");
}
game.honba++;
advanceDealer(game, handResult.dealerContinues);
summary.players = game.players.map((p) => ({ seat: p.seat, score: p.score }));
finalizeSummary(reasons);
return summary;
}
summary.type = "win"; summary.winners = [];
const isDealerWin = handResult.winners.some((w) => w.seat === game.dealerSeat);
const reasons = {};
if (handResult.discarderSeat === null) {
const w = handResult.winners[0];
const score = Yaku.calcScore(w.result, w.seat === game.dealerSeat);
const honbaEach = game.honba * 100;
const riichiPicked = game.riichiSticks * 1000;
if (w.seat === game.dealerSeat) {
const each = score.tsumoPayments.detail[0] + honbaEach;
for (const p of game.players) if (p.seat !== w.seat) { p.score -= each; reasons[p.seat] = "ツモ支払い"; }
game.players[w.seat].score += each * 3;
} else {
const dealerSeat = game.dealerSeat;
for (let i = 0; i < 4; i++) { if (i === w.seat) continue; const amt = (i === dealerSeat ? score.tsumoPayments.detail[0] : score.tsumoPayments.detail[1]) + honbaEach; game.players[i].score -= amt; game.players[w.seat].score += amt; reasons[i] = "ツモ支払い"; }
}
game.players[w.seat].score += riichiPicked; game.riichiSticks = 0;
let winLabel = `ツモ(基本${score.tsumoPayments.total}点`;
if (honbaEach > 0) winLabel += `+本場${honbaEach * 3}点`;
if (riichiPicked > 0) winLabel += `+供託${riichiPicked}点`;
winLabel += ")";
reasons[w.seat] = winLabel;
summary.winners.push({ seat: w.seat, han: w.result.totalHan, fu: w.result.fu, yaku: w.result.yaku, label: score.label, hand: game.players[w.seat].hand.slice(), melds: game.players[w.seat].melds.map((m) => ({ type: m.type, tiles: m.tiles.slice() })), winTile: w.winTile, isTsumo: w.isTsumo });
} else {
const honbaTotal = game.honba * 300;
const riichiPicked = game.riichiSticks * 1000;
for (const w of handResult.winners) {
const score = Yaku.calcScore(w.result, w.seat === game.dealerSeat);
const total = score.ronTotal + honbaTotal;
game.players[handResult.discarderSeat].score -= total;
game.players[w.seat].score += total;
let winLabel = `ロン(基本${score.ronTotal}点`;
if (honbaTotal > 0) winLabel += `+本場${honbaTotal}点`;
if (w.seat === handResult.winners[0].seat && riichiPicked > 0) winLabel += `+供託${riichiPicked}点`;
winLabel += ")";
reasons[w.seat] = winLabel;
summary.winners.push({ seat: w.seat, han: w.result.totalHan, fu: w.result.fu, yaku: w.result.yaku, label: score.label, hand: game.players[w.seat].hand.slice(), melds: game.players[w.seat].melds.map((m) => ({ type: m.type, tiles: m.tiles.slice() })), winTile: w.winTile, isTsumo: w.isTsumo });
}
reasons[handResult.discarderSeat] = handResult.winners.length > 1 ? "放銃(ダブロン)" : "放銃(ロン)";
game.players[handResult.winners[0].seat].score += riichiPicked; game.riichiSticks = 0;
}
if (isDealerWin) game.honba++; else game.honba = 0;
advanceDealer(game, isDealerWin);
summary.players = game.players.map((p) => ({ seat: p.seat, score: p.score }));
finalizeSummary(reasons);
return summary;
}
function advanceDealer(game, dealerContinues) {
if (dealerContinues) return;
game.dealerSeat = (game.dealerSeat + 1) % 4;
game.roundNumber++;
}
function isGameOver(game) {
if (game.players.some((p) => p.score < 0)) return true;
if (game.players.some((p) => p.score === 0)) return true;
if (game.roundNumber > 4) return true;
return false;
}
return { playHand, isGameOver, canChi };
})(window.MJCore, window.MJYaku, window.MJGame, window.MJCpu);
/* ===== UI(画面表示) ===== */
/* ==================== UI ==================== */
(function () {
"use strict";
const Core = window.MJCore, Yaku = window.MJYaku, G = window.MJGame, Cpu = window.MJCpu, Ctl = window.MJController;
const tileTypeOf = G.tileType, isRedId = G.isRedId;
function tileGlyph(type) {
if (type < 9) return String.fromCodePoint(0x1F007 + type);
if (type < 18) return String.fromCodePoint(0x1F019 + (type - 9));
if (type < 27) return String.fromCodePoint(0x1F010 + (type - 18));
const map = { 27: 0x1F000, 28: 0x1F001, 29: 0x1F002, 30: 0x1F003, 31: 0x1F006, 32: 0x1F005, 33: 0x1F004 };
return String.fromCodePoint(map[type]);
}
/* ---------- 牌イメージ(ユーザー提供のカスタム牌画像) ---------- */
const TILE_IMG_BASE = "https://attachstyle.com/wp-content/uploads/2026/09/";
const TILE_IMG_NAMES = (function () {
const arr = [];
for (let i = 1; i <= 9; i++) arr.push("mjp_m" + i);
for (let i = 1; i <= 9; i++) arr.push("mjp_p" + i);
for (let i = 1; i <= 9; i++) arr.push("mjp_s" + i);
arr.push("mjp_ton", "mjp_nan", "mjp_sya", "mjp_pei", "mjp_haku", "mjp_hatsu", "mjp_chun");
return arr;
})();
function tileImageURL(type) { return TILE_IMG_BASE + TILE_IMG_NAMES[type] + ".png"; }
const SEAT_LABELS = ["自分(東家基準:座席0)", "座席1", "座席2", "座席3"];
let game, gen, pendingPhase = null, riichiArmed = false, chosenChars = [], gameEnded = false;
let modalOutsideClosable = false; // 局終了・最終結果のモーダルは進行に必須のボタンがあるため外側クリックでは閉じない。戦績画面のみ許可する
let highlightSeat = null, newestDiscardSeat = null;
let humanAvatarChoice = "male";
/* ---------- 戦績記録(localStorage) ---------- */
const RECORDS_KEY = "mahjong4_records_v1";
let records = null;
function defaultRecords() {
const chars = {};
for (const c of Cpu.CHARACTERS) chars[c.id] = { games: 0, wins: 0, agariCount: 0 };
return {
version: 1,
human: { gamesPlayed: 0, rankCounts: [0, 0, 0, 0], agariCount: 0 },
avatars: { male: { games: 0, wins: 0 }, female: { games: 0, wins: 0 } },
chars,
};
}
function loadRecords() {
if (records) return records;
const def = defaultRecords();
try {
const raw = localStorage.getItem(RECORDS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
records = def;
if (parsed && parsed.human) Object.assign(records.human, parsed.human);
if (parsed && parsed.avatars) { for (const k in parsed.avatars) if (records.avatars[k]) Object.assign(records.avatars[k], parsed.avatars[k]); }
if (parsed && parsed.chars) { for (const k in parsed.chars) if (records.chars[k]) Object.assign(records.chars[k], parsed.chars[k]); }
} else {
records = def;
}
} catch (e) {
records = def;
}
return records;
}
function saveRecords() {
try { localStorage.setItem(RECORDS_KEY, JSON.stringify(records)); } catch (e) { /* localStorageが使えない環境では保存をあきらめ、ゲーム自体は継続する */ }
}
function resetRecords() {
records = defaultRecords();
saveRecords();
}
function recordGameResult(ranked) {
const rec = loadRecords();
const humanRank = ranked.findIndex((p) => p.seat === 0) + 1;
rec.human.gamesPlayed++;
if (humanRank >= 1 && humanRank <= 4) rec.human.rankCounts[humanRank - 1]++;
const av = rec.avatars[humanAvatarChoice] || (rec.avatars[humanAvatarChoice] = { games: 0, wins: 0 });
av.games++;
if (humanRank === 1) av.wins++;
for (let seat = 1; seat <= 3; seat++) {
const ch = game.players[seat].character;
if (!ch || !rec.chars[ch.id]) continue;
const rank = ranked.findIndex((p) => p.seat === seat) + 1;
rec.chars[ch.id].games++;
if (rank === 1) rec.chars[ch.id].wins++;
}
saveRecords();
}
function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; }
function avatarHTML(portrait, pos, emoji) {
if (!portrait) return `${emoji}`;
const posStyle = pos ? ` style="object-position:${pos}"` : "";
return `${emoji}`;
}
function tileEl(type, opts) {
opts = opts || {};
const d = el("div", "tile" + (opts.red ? " red" : "") + (opts.clickable ? " clickable" : "") + (opts.drawn ? " drawn" : "") + (opts.isNew ? " tile-new" : ""));
d.innerHTML = `
${tileGlyph(type)}`;
if (opts.onclick) d.addEventListener("click", opts.onclick);
return d;
}
/* ---------- イントロ画面 ---------- */
function renderIntro() {
const pool = document.getElementById("mj-charPool");
pool.innerHTML = "";
for (const c of Cpu.CHARACTERS) {
const card = el("div", "char-card");
card.innerHTML = `${avatarHTML(c.portrait, c.pos, c.emoji)}