feature(mahjong): 搶槓/ドラ以外の麻雀の役を実装 (#14346)

* ビルドによる自動的なソース更新

* 麻雀関連のキーバリューペアを追加

* 役の定義をまとめてエクスポート

* タイポ修正

* Revert "麻雀関連のキーバリューペアを追加"

This reverts commit c349cdf70c69af89d93ed7db035efaaacf2c2785.

* misskey-jsのビルドによる自動更新

* 型エラーに対処

* riichiがtrueの場合に門前であるかを確認

* EnvForCalcYakuのhouseプロパティを廃止

* 風牌の役の共通部分をクラスで定義

* タイポ修正

* 役牌をクラスで共通化

* 一盃口と二盃口のテストを通す

* 一盃口・二盃口判定関数の調整

* 一気通貫の判定にチーによる順子も考慮する

* 混全帯幺九の実装

* 純全帯幺九の実装

* 七対子の実装とテストの修正

* tsumoTileまたはronTileを必須に

* 待ちを確認して平和の判定を可能に

* 三暗刻と四暗刻、四暗刻単騎の実装

* 四暗刻であるために通常の役を判定できない牌姿のテストを修正

* 混老頭と清老頭を実装

* 三槓子と四槓子を実装

* 平和の実装とテストを修正

* 小三元のテストを修正

* 国士無双に対子の確認を追加

* 国士無双十三面待ちを実装し、テストを修正

* 一部の役の七対子形を認め、テストを追加

* 手牌の数を確認

* 役の定義をカプセル化して型エラーの対処

* ツモ・ロンの判定を修正

* calcYakusの引数のhandTilesを修正

* calcYakusに渡す風をseatWindに修正

* 嶺上開花の実装

* 海底摸月の実装

* FourMentsuOneJyantouWithWait型の作成

* 河底撈魚の実装

* ダブル立直の実装

* 天和・地和の実装

* エンジンのテストを作成

* エンジンによる地和のテストを追加

* 嶺上開花のテスト

* ライセンスの記述を追加

* ダブル立直一発ツモのテスト

* ダブル立直海底ツモのテスト

* ダブル立直河底のテスト

* 役満も処理できるように

* 点数のテスト

* 打牌時にrinshanFlags[house]をfalseに

* 七対子形の字一色を認める

* typo
This commit is contained in:
Take-John
2024-08-15 12:29:31 +09:00
committed by GitHub
parent f32b11ba12
commit bf818a6656
8 changed files with 1417 additions and 492 deletions

View File

@@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CALL_HURO_TYPES, CHAR_TILES, FourMentsuOneJyantou, House, MANZU_TILES, PINZU_TILES, SOUZU_TILES, TileType, YAOCHU_TILES, TILE_TYPES, analyzeFourMentsuOneJyantou, isShuntu, isManzu, isPinzu, isSameNumberTile, isSouzu, isKotsu } from './common.js';
import { CALL_HURO_TYPES, CHAR_TILES, FourMentsuOneJyantou, House, MANZU_TILES, PINZU_TILES, SOUZU_TILES, TileType, YAOCHU_TILES, TILE_TYPES, analyzeFourMentsuOneJyantou, isShuntu, isManzu, isPinzu, isSameNumberTile, isSouzu, isKotsu, includes, TERMINAL_TILES, mentsuEquals, Huro, TILE_ID_MAP } from './common.js';
import { calcWaitPatterns, isRyanmen, isToitsu, FourMentsuOneJyantouWithWait } from './common.fu.js';
const RYUISO_TILES: TileType[] = ['s2', 's3', 's4', 's6', 's8', 'hatsu'];
const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'];
@@ -67,11 +68,27 @@ export const YAKUMAN_NAMES = [
'chiho',
] as const;
export type YakuName = typeof NORMAL_YAKU_NAMES[number] | typeof YAKUMAN_NAMES[number];
type NormalYakuName = typeof NORMAL_YAKU_NAMES[number]
type YakumanName = typeof YAKUMAN_NAMES[number];
export type YakuName = NormalYakuName | YakumanName;
export type HuroForCalcYaku = {
type: 'pon';
tile: TileType;
} | {
type: 'cii';
tiles: [TileType, TileType, TileType];
} | {
type: 'ankan';
tile: TileType;
} | {
type: 'minkan';
tile: TileType;
};
export type EnvForCalcYaku = {
house: House;
/**
* 和了る人の手牌(副露牌は含まず、ツモ、ロン牌は含む)
*/
@@ -80,72 +97,221 @@ export type EnvForCalcYaku = {
/**
* 河
*/
hoTiles: TileType[];
hoTiles?: TileType[];
/**
* 副露
*/
huros: ({
type: 'pon';
tile: TileType;
} | {
type: 'cii';
tiles: [TileType, TileType, TileType];
} | {
type: 'ankan';
tile: TileType;
} | {
type: 'minkan';
tile: TileType;
})[];
tsumoTile: TileType;
ronTile: TileType;
huros: HuroForCalcYaku[];
/**
* 場風
*/
fieldWind: House;
fieldWind?: House;
/**
* 自風
*/
seatWind: House;
seatWind?: House;
/**
* 局が始まってから誰の副露もない一巡目かどうか
*/
firstTurn?: boolean;
/**
* リーチしたかどうか
*/
riichi: boolean;
riichi?: boolean;
/**
* 一巡目以内かどうか
* 誰の副露もない一巡目でリーチしたかどうか
*/
ippatsu: boolean;
doubleRiichi?: boolean;
/**
* リーチしてから誰の副露もない一巡目以内かどうか
*/
ippatsu?: boolean;
} & ({
tsumoTile: TileType;
ronTile?: null;
/**
* 嶺上牌のツモか
*/
rinshan?: boolean;
/**
* 海底牌か
*/
haitei?: boolean;
} | {
tsumoTile?: null;
ronTile: TileType;
/**
* 河底牌か
*/
hotei?: boolean;
});
interface YakuDataBase {
name: YakuName;
upper?: YakuName | null;
fan?: number | null;
isYakuman?: boolean;
}
interface NormalYakuData extends YakuDataBase {
name: NormalYakuName;
fan: number;
isYakuman?: false;
kuisagari?: boolean;
}
interface YakumanData extends YakuDataBase {
name: YakumanName;
isYakuman: true;
isDoubleYakuman?: boolean;
}
export type YakuData = Required<NormalYakuData> | Required<YakumanData>;
abstract class YakuSetBase<IsYakuman extends boolean> {
public readonly isYakuman: IsYakuman;
public readonly yakus: YakuData[];
public get yakuNames(): YakuName[] {
return this.yakus.map(yaku => yaku.name);
}
constructor(isYakuman: IsYakuman, yakus: YakuData[]) {
this.isYakuman = isYakuman;
this.yakus = yakus;
}
}
class NormalYakuSet extends YakuSetBase<false> {
public readonly isMenzen: boolean;
public readonly fan: number;
constructor(isMenzen: boolean, yakus: Required<NormalYakuData>[]) {
super(false, yakus);
this.isMenzen = isMenzen;
this.fan = yakus.reduce((fan, yaku) => fan + (!isMenzen && yaku.kuisagari ? yaku.fan - 1 : yaku.fan), 0);
}
}
class YakumanSet extends YakuSetBase<true> {
/**
* 何倍役満か
*/
public readonly value: number;
constructor(yakus: Required<YakumanData>[]) {
super(true, yakus);
this.value = yakus.reduce((value, yaku) => value + (yaku.isDoubleYakuman ? 2 : 1), 0);
}
}
export type YakuSet = NormalYakuSet | YakumanSet;
type YakuDefinitionBase = {
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => boolean;
};
type YakuDefiniyion = {
name: YakuName;
upper?: YakuName;
fan?: number;
isYakuman?: boolean;
isDoubleYakuman?: boolean;
kuisagari?: boolean;
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => boolean;
};
type NormalYakuDefinition = YakuDefinitionBase & NormalYakuData;
type YakumanDefinition = YakuDefinitionBase & YakumanData;
function countTiles(tiles: TileType[], target: TileType): number {
return tiles.filter(t => t === target).length;
}
export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
class Yakuhai implements NormalYakuDefinition {
readonly name: NormalYakuName;
readonly fan = 1;
readonly isYakuman = false;
readonly tile: typeof CHAR_TILES[number];
constructor(name: NormalYakuName, house: typeof CHAR_TILES[number]) {
this.name = name;
this.tile = house;
}
calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean {
if (fourMentsuOneJyantou == null) return false;
return (
(countTiles(state.handTiles, this.tile) >= 3) ||
(state.huros.some(huro =>
huro.type === 'pon' ? huro.tile === this.tile :
huro.type === 'ankan' ? huro.tile === this.tile :
huro.type === 'minkan' ? huro.tile === this.tile :
false))
);
}
}
class FieldWind extends Yakuhai {
calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean {
return super.calc(state, fourMentsuOneJyantou) && state.fieldWind === this.tile;
}
}
class SeatWind extends Yakuhai {
calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean {
return super.calc(state, fourMentsuOneJyantou) && state.seatWind === this.tile;
}
}
/**
* 2つの同じ面子の組を数える (一盃口なら1、二盃口なら2)
*/
function countIndenticalMentsuPairs(mentsus: [TileType, TileType, TileType][]) {
let result = 0;
const singleMentsus: [TileType, TileType, TileType][] = [];
loop: for (const mentsu of mentsus) {
for (let i = 0 ; i < singleMentsus.length ; i++) {
if (mentsuEquals(mentsu, singleMentsus[i])) {
result++;
singleMentsus.splice(i, 1);
continue loop;
}
}
singleMentsus.push(mentsu);
}
return result;
}
/**
* 暗刻の数を数える (三暗刻なら3、四暗刻なら4)
*/
function countAnkos(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait) {
let ankans = state.huros.filter(huro => huro.type == 'ankan').length;
const handKotsus = fourMentsuOneJyantou.mentsus.filter(mentsu => isKotsu(mentsu)).length;
// ロンによりできた刻子は暗刻ではない
if (state.ronTile != null && fourMentsuOneJyantou.waitedFor == 'mentsu' && isToitsu(fourMentsuOneJyantou.waitedTaatsu)) {
return ankans + handKotsus - 1;
}
return ankans + handKotsus;
}
export const NORMAL_YAKU_DEFINITIONS: NormalYakuDefinition[] = [{
name: 'tsumo',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
// 面前じゃないとダメ
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
// 門前じゃないとダメ
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
return state.tsumoTile != null;
},
@@ -154,173 +320,67 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
return state.riichi;
return !state.doubleRiichi && (state.riichi ?? false);
},
}, {
name: 'double-riichi',
fan: 2,
isYakuman: false,
calc: (state: EnvForCalcYaku) => {
return state.doubleRiichi ?? false;
}
}, {
name: 'ippatsu',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
return state.ippatsu;
return state.ippatsu ?? false;
},
}, {
name: 'red',
name: 'rinshan',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return (
(countTiles(state.handTiles, 'chun') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 'chun' :
huro.type === 'ankan' ? huro.tile === 'chun' :
huro.type === 'minkan' ? huro.tile === 'chun' :
false).length >= 3)
);
},
return (state.tsumoTile != null && state.rinshan) ?? false;
}
}, {
name: 'white',
name: 'haitei',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return (
(countTiles(state.handTiles, 'haku') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 'haku' :
huro.type === 'ankan' ? huro.tile === 'haku' :
huro.type === 'minkan' ? huro.tile === 'haku' :
false).length >= 3)
);
},
return (state.tsumoTile != null && state.haitei) ?? false;
}
}, {
name: 'green',
name: 'hotei',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return (
(countTiles(state.handTiles, 'hatsu') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 'hatsu' :
huro.type === 'ankan' ? huro.tile === 'hatsu' :
huro.type === 'minkan' ? huro.tile === 'hatsu' :
false).length >= 3)
);
},
}, {
name: 'field-wind-e',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return state.fieldWind === 'e' && (
(countTiles(state.handTiles, 'e') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 'e' :
huro.type === 'ankan' ? huro.tile === 'e' :
huro.type === 'minkan' ? huro.tile === 'e' :
false).length >= 3)
);
},
}, {
name: 'field-wind-s',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return state.fieldWind === 's' && (
(countTiles(state.handTiles, 's') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 's' :
huro.type === 'ankan' ? huro.tile === 's' :
huro.type === 'minkan' ? huro.tile === 's' :
false).length >= 3)
);
},
}, {
name: 'seat-wind-e',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return state.house === 'e' && (
(countTiles(state.handTiles, 'e') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 'e' :
huro.type === 'ankan' ? huro.tile === 'e' :
huro.type === 'minkan' ? huro.tile === 'e' :
false).length >= 3)
);
},
}, {
name: 'seat-wind-s',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return state.house === 's' && (
(countTiles(state.handTiles, 's') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 's' :
huro.type === 'ankan' ? huro.tile === 's' :
huro.type === 'minkan' ? huro.tile === 's' :
false).length >= 3)
);
},
}, {
name: 'seat-wind-w',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return state.house === 'w' && (
(countTiles(state.handTiles, 'w') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 'w' :
huro.type === 'ankan' ? huro.tile === 'w' :
huro.type === 'minkan' ? huro.tile === 'w' :
false).length >= 3)
);
},
}, {
name: 'seat-wind-n',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return state.house === 'n' && (
(countTiles(state.handTiles, 'n') >= 3) ||
(state.huros.filter(huro =>
huro.type === 'pon' ? huro.tile === 'n' :
huro.type === 'ankan' ? huro.tile === 'n' :
huro.type === 'minkan' ? huro.tile === 'n' :
false).length >= 3)
);
},
}, {
return (state.ronTile != null && state.hotei) ?? false;
}
},
new Yakuhai('red', 'chun'),
new Yakuhai('white', 'haku'),
new Yakuhai('green', 'hatsu'),
new FieldWind('field-wind-e', 'e'),
new FieldWind('field-wind-s', 's'),
new FieldWind('field-wind-w', 'w'),
new FieldWind('field-wind-n', 'n'),
new SeatWind('seat-wind-e', 'e'),
new SeatWind('seat-wind-s', 's'),
new SeatWind('seat-wind-w', 'w'),
new SeatWind('seat-wind-n', 'n'),
{
name: 'tanyao',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
return (
(!state.handTiles.some(t => YAOCHU_TILES.includes(t))) &&
(!state.handTiles.some(t => includes(YAOCHU_TILES, t))) &&
(state.huros.filter(huro =>
huro.type === 'pon' ? YAOCHU_TILES.includes(huro.tile) :
huro.type === 'ankan' ? YAOCHU_TILES.includes(huro.tile) :
huro.type === 'minkan' ? YAOCHU_TILES.includes(huro.tile) :
huro.type === 'cii' ? huro.tiles.some(t2 => YAOCHU_TILES.includes(t2)) :
huro.type === 'pon' ? includes(YAOCHU_TILES, huro.tile) :
huro.type === 'ankan' ? includes(YAOCHU_TILES, huro.tile) :
huro.type === 'minkan' ? includes(YAOCHU_TILES, huro.tile) :
huro.type === 'cii' ? huro.tiles.some(t2 => includes(YAOCHU_TILES, t2)) :
false).length === 0)
);
},
@@ -328,15 +388,16 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
name: 'pinfu',
fan: 1,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
if (fourMentsuOneJyantou == null) return false;
// 面前じゃないとダメ
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
// 三元牌はダメ
if (state.handTiles.some(t => ['haku', 'hatsu', 'chun'].includes(t))) return false;
// TODO: 両面待ちかどうか
// 両面待ちかどうか
if (!(fourMentsuOneJyantou != null && fourMentsuOneJyantou.waitedFor == 'mentsu' && isRyanmen(fourMentsuOneJyantou.waitedTaatsu))) return false;
// 風牌判定(役牌でなければOK)
if (fourMentsuOneJyantou.head === state.seatWind) return false;
@@ -353,20 +414,18 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
isYakuman: false,
kuisagari: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
const tiles = state.handTiles;
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
let charCount = tiles.filter(t => CHAR_TILES.includes(t)).length;
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
let charCount = tiles.filter(t => includes(CHAR_TILES, t)).length;
for (const huro of state.huros) {
const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile];
manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length;
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
charCount += huroTiles.filter(t => CHAR_TILES.includes(t)).length;
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
charCount += huroTiles.filter(t => includes(CHAR_TILES, t)).length;
}
if (manzuCount > 0 && pinzuCount > 0) return false;
@@ -382,20 +441,18 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
isYakuman: false,
kuisagari: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
const tiles = state.handTiles;
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
let charCount = tiles.filter(t => CHAR_TILES.includes(t)).length;
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
let charCount = tiles.filter(t => includes(CHAR_TILES, t)).length;
for (const huro of state.huros) {
const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile];
manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length;
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
charCount += huroTiles.filter(t => CHAR_TILES.includes(t)).length;
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
charCount += huroTiles.filter(t => includes(CHAR_TILES, t)).length;
}
if (charCount > 0) return false;
@@ -413,12 +470,23 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
if (fourMentsuOneJyantou == null) return false;
// 面前じゃないとダメ
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
// 同じ順子が2つあるか
return fourMentsuOneJyantou.mentsus.some((mentsu) =>
fourMentsuOneJyantou.mentsus.filter((mentsu2) =>
mentsu2[0] === mentsu[0] && mentsu2[1] === mentsu[1] && mentsu2[2] === mentsu[2]).length >= 2);
return countIndenticalMentsuPairs(fourMentsuOneJyantou.mentsus) == 1;
},
}, {
name: 'ryampeko',
fan: 3,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
// 面前じゃないとダメ
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
// 2つの同じ順子が2組あるか
return countIndenticalMentsuPairs(fourMentsuOneJyantou.mentsus) == 2;
},
}, {
name: 'toitoi',
@@ -440,9 +508,25 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
name: 'sananko',
fan: 2,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
return fourMentsuOneJyantou != null && countAnkos(state, fourMentsuOneJyantou) == 3;
},
}, {
name: 'honroto',
fan: 2,
isYakuman: false,
calc: (state: EnvForCalcYaku) => {
return state.huros.every(huro => huro.type != 'cii' && includes(YAOCHU_TILES, huro.tile)) &&
state.handTiles.every(tile => includes(YAOCHU_TILES, tile));
}
}, {
name: 'sankantsu',
fan: 2,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
return fourMentsuOneJyantou != null &&
state.huros.filter(huro => huro.type == 'ankan' || huro.type == 'minkan').length == 3;
}
}, {
name: 'sanshoku-dojun',
fan: 2,
@@ -520,6 +604,7 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
if (fourMentsuOneJyantou == null) return false;
const shuntsus = fourMentsuOneJyantou.mentsus.filter(tiles => isShuntu(tiles));
shuntsus.push(...state.huros.filter(huro => huro.type == 'cii').map(huro => huro.tiles));
if (shuntsus.some(tiles => tiles[0] === 'm1' && tiles[1] === 'm2' && tiles[2] === 'm3')) {
if (shuntsus.some(tiles => tiles[0] === 'm4' && tiles[1] === 'm5' && tiles[2] === 'm6')) {
@@ -545,11 +630,63 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
return false;
},
}, {
name: 'chanta',
fan: 2,
isYakuman: false,
kuisagari: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
const { head, mentsus } = fourMentsuOneJyantou;
const { huros } = state;
// 雀頭は幺九牌じゃないとダメ
if (!includes(YAOCHU_TILES, head)) return false;
// 順子は1つ以上じゃないとダメ
if (!mentsus.some(mentsu => isShuntu(mentsu))) return false;
// いずれかの雀頭か面子に字牌を含まないとダメ
if (!(includes(CHAR_TILES, head) ||
mentsus.some(mentsu => includes(CHAR_TILES, mentsu[0])) ||
huros.some(huro => huro.type != 'cii' && includes(CHAR_TILES, huro.tile)))) return false;
// 全ての面子に幺九牌が含まれる
return (mentsus.every(mentsu => mentsu.some(tile => includes(YAOCHU_TILES, tile))) &&
huros.every(huro => huro.type == 'cii' ?
huro.tiles.some(tile => includes(YAOCHU_TILES, tile)) :
includes(YAOCHU_TILES, huro.tile)));
},
}, {
name: 'junchan',
fan: 3,
isYakuman: false,
kuisagari: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
const { head, mentsus } = fourMentsuOneJyantou;
const { huros } = state;
// 雀頭は老頭牌じゃないとダメ
if (!includes(TERMINAL_TILES, head)) return false;
// 順子は1つ以上じゃないとダメ
if (!mentsus.some(mentsu => isShuntu(mentsu))) return false;
// 全ての面子に老頭牌が含まれる
return (mentsus.every(mentsu => mentsu.some(tile => includes(TERMINAL_TILES, tile))) &&
huros.every(huro => huro.type == 'cii' ?
huro.tiles.some(tile => includes(TERMINAL_TILES, tile)) :
includes(TERMINAL_TILES, huro.tile)));
},
}, {
name: 'chitoitsu',
fan: 2,
isYakuman: false,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou != null) return false;
if (state.huros.length > 0) return false;
const countMap = new Map<TileType, number>();
for (const tile of state.handTiles) {
@@ -587,7 +724,21 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
},
}];
export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
export const YAKUMAN_DEFINITIONS: YakumanDefinition[] = [{
name: 'suanko-tanki',
isYakuman: true,
isDoubleYakuman: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
return fourMentsuOneJyantou != null && fourMentsuOneJyantou.waitedFor == 'head' && countAnkos(state, fourMentsuOneJyantou) == 4;
}
}, {
name: 'suanko',
isYakuman: true,
upper: 'suanko-tanki',
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
return fourMentsuOneJyantou != null && countAnkos(state, fourMentsuOneJyantou) == 4;
}
}, {
name: 'daisangen',
isYakuman: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
@@ -656,19 +807,17 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
}, {
name: 'tsuiso',
isYakuman: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
if (fourMentsuOneJyantou == null) return false;
calc: (state: EnvForCalcYaku) => {
const tiles = state.handTiles;
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
for (const huro of state.huros) {
const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile];
manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length;
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
}
if (manzuCount > 0 || pinzuCount > 0 || souzuCount > 0) return false;
@@ -690,6 +839,21 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
return true;
},
}, {
name: 'chinroto',
isYakuman: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
return fourMentsuOneJyantou != null &&
state.huros.every(huro => huro.type != 'cii' && includes(TERMINAL_TILES, huro.tile)) &&
state.handTiles.every(tile => includes(TERMINAL_TILES, tile));
}
}, {
name: 'sukantsu',
isYakuman: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
return fourMentsuOneJyantou != null &&
state.huros.filter(huro => huro.type == 'ankan' || huro.type == 'minkan').length == 4;
}
}, {
name: 'churen-9',
isYakuman: true,
@@ -698,9 +862,12 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
if (fourMentsuOneJyantou == null) return false;
// 面前じゃないとダメ
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
const agariTile = state.tsumoTile ?? state.ronTile;
if (agariTile == null) {
return false;
}
const tempaiTiles = [...state.handTiles];
tempaiTiles.splice(state.handTiles.indexOf(agariTile), 1);
@@ -734,7 +901,7 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
if (fourMentsuOneJyantou == null) return false;
// 面前じゃないとダメ
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
if (isManzu(state.handTiles[0])) {
if ((countTiles(state.handTiles, 'm1') === 3) && (countTiles(state.handTiles, 'm9') === 3)) {
@@ -758,44 +925,120 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
return false;
},
}, {
name: 'kokushi-13',
isYakuman: true,
isDoubleYakuman: true,
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
const agariTile = state.tsumoTile ?? state.ronTile;
return KOKUSHI_TILES.every(t => state.handTiles.includes(t)) && countTiles(state.handTiles, agariTile) == 2;
}
}, {
name: 'kokushi',
isYakuman: true,
upper: 'kokushi-13',
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
return KOKUSHI_TILES.every(t => state.handTiles.includes(t));
return KOKUSHI_TILES.every(t => state.handTiles.includes(t)) && KOKUSHI_TILES.some(t => countTiles(state.handTiles, t) == 2);
},
}, {
name: 'tenho',
isYakuman: true,
calc: (state: EnvForCalcYaku) => {
return (state.firstTurn ?? false) && state.tsumoTile != null && state.seatWind == 'e';
}
}, {
name: 'chiho',
isYakuman: true,
calc: (state: EnvForCalcYaku) => {
return (state.firstTurn ?? false) && state.tsumoTile != null && state.seatWind != 'e';
}
}];
export const YAKU_DEFINITIONS = NORMAL_YAKU_DEFINITIONS.concat(YAKUMAN_DEFINITIONS);
export function convertHuroForCalcYaku(huro: Huro): HuroForCalcYaku {
switch (huro.type) {
case 'pon':
case 'ankan':
case 'minkan':
return {
type: huro.type,
tile: TILE_ID_MAP.get(huro.tiles[0])!.t,
}
case 'cii':
return {
type: 'cii',
tiles: huro.tiles.map(tile => TILE_ID_MAP.get(tile)!.t) as [TileType, TileType, TileType],
};
}
}
const NORMAL_YAKU_DATA_MAP = new Map<NormalYakuName, Required<NormalYakuData>>(
NORMAL_YAKU_DEFINITIONS.map(yaku => [yaku.name, {
name: yaku.name,
upper: yaku.upper ?? null,
fan: yaku.fan,
isYakuman: false,
kuisagari: yaku.kuisagari ?? false,
}] as const)
);
const YAKUMAN_DATA_MAP = new Map<YakuName, Required<YakumanData>>(
YAKUMAN_DEFINITIONS.map(yaku => [yaku.name, {
name: yaku.name,
upper: yaku.upper ?? null,
fan: null,
isYakuman: true,
isDoubleYakuman: yaku.isDoubleYakuman ?? false,
}])
);
export function calcYakusWithDetail(state: EnvForCalcYaku): YakuSet {
if (state.riichi && state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type)) ) {
throw new TypeError('Invalid riichi state with call huros');
}
const agariTile = state.tsumoTile ?? state.ronTile;
if (!state.handTiles.includes(agariTile)) {
throw new TypeError('Agari tile not included in hand tiles');
}
if (state.handTiles.length + state.huros.length * 3 != 14) {
throw new TypeError('Invalid tile count');
}
export function calcYakus(state: EnvForCalcYaku): YakuName[] {
const oneHeadFourMentsuPatterns: (FourMentsuOneJyantou | null)[] = analyzeFourMentsuOneJyantou(state.handTiles);
if (oneHeadFourMentsuPatterns.length === 0) oneHeadFourMentsuPatterns.push(null);
const yakumanPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => {
const matchedYakus: YakuDefiniyion[] = [];
for (const yakuDef of YAKUMAN_DEFINITIONS) {
if (yakuDef.upper && matchedYakus.some(yaku => yaku.name === yakuDef.upper)) continue;
const matched = yakuDef.calc(state, fourMentsuOneJyantou);
if (matched) {
matchedYakus.push(yakuDef);
const waitPatterns = oneHeadFourMentsuPatterns.map(
fourMentsuOneJyantou => calcWaitPatterns(fourMentsuOneJyantou, agariTile)
).flat();
const yakumanPatterns = waitPatterns.map(fourMentsuOneJyantouWithWait => {
const matchedYakus: Required<YakumanData>[] = [];
for (const yakuDef of YAKUMAN_DEFINITIONS) {
if (yakuDef.upper && matchedYakus.some(yaku => yaku.name === yakuDef.upper)) continue;
const matched = yakuDef.calc(state, fourMentsuOneJyantouWithWait);
if (matched) {
matchedYakus.push(YAKUMAN_DATA_MAP.get(yakuDef.name)!);
}
}
}
return matchedYakus;
}).filter(yakus => yakus.length > 0);
return matchedYakus;
}).filter(yakus => yakus.length > 0);
if (yakumanPatterns.length > 0) {
return yakumanPatterns[0].map(yaku => yaku.name);
return new YakumanSet(yakumanPatterns[0]);
}
const yakuPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => {
return NORMAL_YAKU_DEFINITIONS.map(yakuDef => {
const result = yakuDef.calc(state, fourMentsuOneJyantou);
return result ? yakuDef : null;
}).filter(yaku => yaku != null) as YakuDefiniyion[];
}).filter(yakus => yakus.length > 0);
const yakuPatterns = waitPatterns.map(
fourMentsuOneJyantouWithWait => NORMAL_YAKU_DEFINITIONS.filter(
yakuDef => yakuDef.calc(state, fourMentsuOneJyantouWithWait)
).map(yakuDef => NORMAL_YAKU_DATA_MAP.get(yakuDef.name)!)
).filter(yakus => yakus.length > 0);
const isMenzen = state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type));
const isMenzen = state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type));
if (yakuPatterns.length == 0) {
return new NormalYakuSet(isMenzen, []);
}
let maxYakus = yakuPatterns[0];
let maxFan = 0;
@@ -814,5 +1057,9 @@ export function calcYakus(state: EnvForCalcYaku): YakuName[] {
}
}
return maxYakus.map(yaku => yaku.name);
return new NormalYakuSet(isMenzen, maxYakus);
}
export function calcYakus(state: EnvForCalcYaku): YakuName[] {
return calcYakusWithDetail(state).yakuNames;
}