From bf818a665600625b73b3ec0f2670dada92caef54 Mon Sep 17 00:00:00 2001 From: Take-John <105504345+takejohn@users.noreply.github.com> Date: Thu, 15 Aug 2024 12:29:31 +0900 Subject: [PATCH] =?UTF-8?q?feature(mahjong):=20=E6=90=B6=E6=A7=93/?= =?UTF-8?q?=E3=83=89=E3=83=A9=E4=BB=A5=E5=A4=96=E3=81=AE=E9=BA=BB=E9=9B=80?= =?UTF-8?q?=E3=81=AE=E5=BD=B9=E3=82=92=E5=AE=9F=E8=A3=85=20(#14346)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ビルドによる自動的なソース更新 * 麻雀関連のキーバリューペアを追加 * 役の定義をまとめてエクスポート * タイポ修正 * Revert "麻雀関連のキーバリューペアを追加" This reverts commit c349cdf70c69af89d93ed7db035efaaacf2c2785. * misskey-jsのビルドによる自動更新 * 型エラーに対処 * riichiがtrueの場合に門前であるかを確認 * EnvForCalcYakuのhouseプロパティを廃止 * 風牌の役の共通部分をクラスで定義 * タイポ修正 * 役牌をクラスで共通化 * 一盃口と二盃口のテストを通す * 一盃口・二盃口判定関数の調整 * 一気通貫の判定にチーによる順子も考慮する * 混全帯幺九の実装 * 純全帯幺九の実装 * 七対子の実装とテストの修正 * tsumoTileまたはronTileを必須に * 待ちを確認して平和の判定を可能に * 三暗刻と四暗刻、四暗刻単騎の実装 * 四暗刻であるために通常の役を判定できない牌姿のテストを修正 * 混老頭と清老頭を実装 * 三槓子と四槓子を実装 * 平和の実装とテストを修正 * 小三元のテストを修正 * 国士無双に対子の確認を追加 * 国士無双十三面待ちを実装し、テストを修正 * 一部の役の七対子形を認め、テストを追加 * 手牌の数を確認 * 役の定義をカプセル化して型エラーの対処 * ツモ・ロンの判定を修正 * calcYakusの引数のhandTilesを修正 * calcYakusに渡す風をseatWindに修正 * 嶺上開花の実装 * 海底摸月の実装 * FourMentsuOneJyantouWithWait型の作成 * 河底撈魚の実装 * ダブル立直の実装 * 天和・地和の実装 * エンジンのテストを作成 * エンジンによる地和のテストを追加 * 嶺上開花のテスト * ライセンスの記述を追加 * ダブル立直一発ツモのテスト * ダブル立直海底ツモのテスト * ダブル立直河底のテスト * 役満も処理できるように * 点数のテスト * 打牌時にrinshanFlags[house]をfalseに * 七対子形の字一色を認める * typo --- packages/misskey-mahjong/src/common.fu.ts | 63 ++ packages/misskey-mahjong/src/common.ts | 57 +- packages/misskey-mahjong/src/common.yaku.ts | 737 ++++++++++++------ packages/misskey-mahjong/src/engine.master.ts | 196 +++-- packages/misskey-mahjong/src/engine.player.ts | 70 +- packages/misskey-mahjong/test/engine.ts | 235 ++++++ packages/misskey-mahjong/test/fu.ts | 70 ++ packages/misskey-mahjong/test/yaku.ts | 481 ++++++++---- 8 files changed, 1417 insertions(+), 492 deletions(-) create mode 100644 packages/misskey-mahjong/src/common.fu.ts create mode 100644 packages/misskey-mahjong/test/engine.ts create mode 100644 packages/misskey-mahjong/test/fu.ts diff --git a/packages/misskey-mahjong/src/common.fu.ts b/packages/misskey-mahjong/src/common.fu.ts new file mode 100644 index 0000000000..820529dce1 --- /dev/null +++ b/packages/misskey-mahjong/src/common.fu.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FourMentsuOneJyantou, mentsuEquals, TILE_NUMBER_MAP, TileType } from "./common.js"; + +export type Shape = 'fourMentsuOneJyantou' | 'chitoitsu' | 'kokushi'; + +/** + * 4面子1雀頭と待ちに関わる部分 + */ +export type FourMentsuOneJyantouWithWait = FourMentsuOneJyantou & { + agariTile: TileType; +} & ({ + waitedFor: 'head'; +} | { + waitedFor: 'mentsu'; + waitedTaatsu: [TileType, TileType]; +}); + +export function calcWaitPatterns(fourMentsuOneJyantou: FourMentsuOneJyantou | null, agariTile: TileType): FourMentsuOneJyantouWithWait[] | [null] { + if (fourMentsuOneJyantou == null) return [null]; + + const result: FourMentsuOneJyantouWithWait[] = []; + + if (fourMentsuOneJyantou.head == agariTile) { + result.push({ + head: fourMentsuOneJyantou.head, + mentsus: fourMentsuOneJyantou.mentsus, + waitedFor: 'head', + agariTile, + }); + } + + const checkedMentsus: [TileType, TileType, TileType][] = []; + for (const mentsu of fourMentsuOneJyantou.mentsus) { + if (checkedMentsus.some(checkedMentsu => mentsuEquals(mentsu, checkedMentsu))) continue; + const agariTileIndex = mentsu.indexOf(agariTile); + if (agariTileIndex < 0) continue; + result.push({ + head: fourMentsuOneJyantou.head, + mentsus: fourMentsuOneJyantou.mentsus, + waitedFor: 'mentsu', + agariTile, + waitedTaatsu: mentsu.toSpliced(agariTileIndex, 1) as [TileType, TileType], + }) + checkedMentsus.push(mentsu); + } + + return result; +} + +export function isRyanmen(taatsu: [TileType, TileType]): boolean { + const number1 = TILE_NUMBER_MAP[taatsu[0]]; + const number2 = TILE_NUMBER_MAP[taatsu[1]]; + if (number1 == null || number2 == null) return false; + return number1 != 1 && number2 != 9 && number1 + 1 == number2; +} + +export function isToitsu(taatsu: [TileType, TileType]): boolean { + return taatsu[0] == taatsu[1]; +} diff --git a/packages/misskey-mahjong/src/common.ts b/packages/misskey-mahjong/src/common.ts index 18ac16ee32..265d6b513f 100644 --- a/packages/misskey-mahjong/src/common.ts +++ b/packages/misskey-mahjong/src/common.ts @@ -109,21 +109,29 @@ export type House = 'e' | 's' | 'w' | 'n'; */ export type Huro = { type: 'pon'; - tiles: [TileId, TileId, TileId]; + tiles: readonly [TileId, TileId, TileId]; from: House; } | { type: 'cii'; - tiles: [TileId, TileId, TileId]; + tiles: readonly [TileId, TileId, TileId]; from: House; } | { type: 'ankan'; - tiles: [TileId, TileId, TileId, TileId]; + tiles: readonly [TileId, TileId, TileId, TileId]; } | { type: 'minkan'; - tiles: [TileId, TileId, TileId, TileId]; + tiles: readonly [TileId, TileId, TileId, TileId]; from: House | null; // null で加槓 }; +export type PointFactor = { + isYakuman: false; + fan: number; +} | { + isYakuman: true; + value: number; +} + export const CALL_HURO_TYPES = ['pon', 'cii', 'minkan'] as const; export const NEXT_TILE_FOR_DORA_MAP: Record = { @@ -279,18 +287,23 @@ export const PINZU_TILES = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9' export const SOUZU_TILES = ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9'] as const satisfies TileType[]; export const CHAR_TILES = ['e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[]; export const YAOCHU_TILES = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[]; +export const TERMINAL_TILES = ['m1', 'm9', 'p1', 'p9', 's1', 's9'] as const satisfies TileType[]; const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; -export function isManzu(tile: T): tile is typeof MANZU_TILES[number] { - return MANZU_TILES.includes(tile); +export function includes>(array: A, searchElement: unknown): searchElement is A[number] { + return array.includes(searchElement); } -export function isPinzu(tile: T): tile is typeof PINZU_TILES[number] { - return PINZU_TILES.includes(tile); +export function isManzu(tile: TileType): tile is typeof MANZU_TILES[number] { + return includes(MANZU_TILES, tile); } -export function isSouzu(tile: T): tile is typeof SOUZU_TILES[number] { - return SOUZU_TILES.includes(tile); +export function isPinzu(tile: TileType): tile is typeof PINZU_TILES[number] { + return includes(PINZU_TILES, tile); +} + +export function isSouzu(tile: TileType): tile is typeof SOUZU_TILES[number] { + return includes(SOUZU_TILES, tile); } export function isSameNumberTile(a: TileType, b: TileType): boolean { @@ -328,16 +341,24 @@ export function fanToPoint(fan: number, isParent: boolean): number { return point; } +export function calcPoint(factor: PointFactor, isParent: boolean): number { + if (factor.isYakuman) { + return 32000 * factor.value * (isParent ? 1.5 : 1); + } else { + return fanToPoint(factor.fan, isParent); + } +} + export function calcOwnedDoraCount(handTiles: TileType[], huros: Huro[], doras: TileType[]): number { let count = 0; for (const t of handTiles) { if (doras.includes(t)) count++; } for (const huro of huros) { - if (huro.type === 'pon' && doras.includes(huro.tile)) count += 3; - if (huro.type === 'cii') count += huro.tiles.filter(t => doras.includes(t)).length; - if (huro.type === 'minkan' && doras.includes(huro.tile)) count += 4; - if (huro.type === 'ankan' && doras.includes(huro.tile)) count += 4; + if (huro.type === 'pon' && includes(doras, huro.tiles[0])) count += 3; + if (huro.type === 'cii') count += huro.tiles.filter(t => includes(doras, t)).length; + if (huro.type === 'minkan' && includes(doras, huro.tiles[0])) count += 4; + if (huro.type === 'ankan' && includes(doras, huro.tiles[0])) count += 4; } return count; } @@ -355,7 +376,7 @@ export function calcRedDoraCount(handTiles: TileId[], huros: Huro[]): number { return count; } -export function calcTsumoHoraPointDeltas(house: House, fans: number): Record { +export function calcTsumoHoraPointDeltas(house: House, fansOrFactor: number | PointFactor): Record { const isParent = house === 'e'; const deltas: Record = { @@ -365,7 +386,7 @@ export function calcTsumoHoraPointDeltas(house: House, fans: number): Record | Required; + +abstract class YakuSetBase { + 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 { + public readonly isMenzen: boolean; + + public readonly fan: number; + + constructor(isMenzen: boolean, yakus: Required[]) { + 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 { + /** + * 何倍役満か + */ + public readonly value: number; + + constructor(yakus: Required[]) { + 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(); 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>( + 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>( + 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[] = []; + 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; } diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts index 45c10cfc74..f27fafd011 100644 --- a/packages/misskey-mahjong/src/engine.master.ts +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -7,7 +7,9 @@ import CRC32 from 'crc-32'; import { TileType, House, Huro, TileId } from './common.js'; import * as Common from './common.js'; import { PlayerState } from './engine.player.js'; -import { YAKU_DEFINITIONS } from "./common.yaku.js"; +import { calcYakusWithDetail, convertHuroForCalcYaku, YakuData, YakuSet } from './common.yaku.js'; + +export const INITIAL_POINT = 25000; //#region syntax suger function $(tid: TileId): Common.TileInstance { @@ -134,13 +136,33 @@ class StateManager { pattern.filter(t => hand.includes(t)).length >= 2); } - public tsumo(): TileId { - const tile = this.$state.tiles.pop(); + private withTsumoTile(tile: TileId | undefined, isRinshan: boolean): TileId { if (tile == null) throw new Error('No tiles left'); if (this.$state.turn == null) throw new Error('Not your turn'); this.$state.handTiles[this.$state.turn].push(tile); + this.$state.rinshanFlags[this.$state.turn] = isRinshan; return tile; } + + public tsumo(): TileId { + return this.withTsumoTile(this.$state.tiles.pop(), false); + } + + public rinshanTsumo(): TileId { + return this.withTsumoTile(this.$state.tiles.shift(), true); + } + + public clearFirstTurnAndIppatsus(): void { + this.$state.firstTurnFlags.e = false; + this.$state.firstTurnFlags.s = false; + this.$state.firstTurnFlags.w = false; + this.$state.firstTurnFlags.n = false; + + this.$state.ippatsus.e = false; + this.$state.ippatsus.s = false; + this.$state.ippatsus.w = false; + this.$state.ippatsus.n = false; + } } export type MasterState = { @@ -178,18 +200,36 @@ export type MasterState = { w: Huro[]; n: Huro[]; }; + firstTurnFlags: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; riichis: { e: boolean; s: boolean; w: boolean; n: boolean; }; + doubleRiichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; ippatsus: { e: boolean; s: boolean; w: boolean; n: boolean; }; + rinshanFlags: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + } points: { e: number; s: number; @@ -304,7 +344,7 @@ export class MasterGameEngine { return this.stateManager.turn; } - public static createInitialState(): MasterState { + public static createInitialState(preset: Partial = {}): MasterState { const ikasama: TileId[] = [125, 129, 9, 56, 57, 61, 77, 81, 85, 133, 134, 135, 121, 122]; const tiles = shuffle([...Common.TILE_ID_MAP.keys()]); @@ -350,23 +390,41 @@ export class MasterGameEngine { w: [], n: [], }, + firstTurnFlags: { + e: true, + s: true, + w: true, + n: true, + }, riichis: { e: false, s: false, w: false, n: false, }, + doubleRiichis: { + e: false, + s: false, + w: false, + n: false, + }, ippatsus: { e: false, s: false, w: false, n: false, }, + rinshanFlags: { + e: false, + s: false, + w: false, + n: false, + }, points: { - e: 25000, - s: 25000, - w: 25000, - n: 25000, + e: INITIAL_POINT, + s: INITIAL_POINT, + w: INITIAL_POINT, + n: INITIAL_POINT, }, turn: 'e', nextTurnAfterAsking: null, @@ -376,6 +434,7 @@ export class MasterGameEngine { cii: null, kan: null, }, + ...preset, }; } @@ -433,8 +492,14 @@ export class MasterGameEngine { if (riichi) { tx.$state.riichis[house] = true; tx.$state.ippatsus[house] = true; + if (tx.$state.firstTurnFlags[house]) { + tx.$state.doubleRiichis[house] = true; + } } + tx.$state.firstTurnFlags[house] = false; + tx.$state.rinshanFlags[house] = false; + const canRonHouses: House[] = []; switch (house) { case 'e': @@ -548,20 +613,17 @@ export class MasterGameEngine { public commit_kakan(house: House, tid: TileId) { const tx = this.startTransaction(); - const pon = tx.$state.huros[house].find(h => h.type === 'pon' && $type(h.tiles[0]) === $type(tid)); + const pon = tx.$state.huros[house].find(h => h.type === 'pon' && $type(h.tiles[0]) === $type(tid)) as Huro & {type: 'pon'}; if (pon == null) throw new Error('No such pon'); tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(tid), 1); - const tiles = [tid, ...pon.tiles]; + const tiles = [tid, ...pon.tiles] as const; tx.$state.huros[house].push({ type: 'minkan', tiles: tiles, from: pon.from }); - tx.$state.ippatsus.e = false; - tx.$state.ippatsus.s = false; - tx.$state.ippatsus.w = false; - tx.$state.ippatsus.n = false; + tx.clearFirstTurnAndIppatsus(); tx.$state.activatedDorasCount++; - const rinsyan = tx.tsumo(); + const rinsyan = tx.rinshanTsumo(); tx.$commit(); @@ -587,17 +649,14 @@ export class MasterGameEngine { tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t2), 1); tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t3), 1); tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t4), 1); - const tiles = [t1, t2, t3, t4]; + const tiles = [t1, t2, t3, t4] as const; tx.$state.huros[house].push({ type: 'ankan', tiles: tiles }); - tx.$state.ippatsus.e = false; - tx.$state.ippatsus.s = false; - tx.$state.ippatsus.w = false; - tx.$state.ippatsus.n = false; + tx.clearFirstTurnAndIppatsus(); tx.$state.activatedDorasCount++; - const rinsyan = tx.tsumo(); + const rinsyan = tx.rinshanTsumo(); tx.$commit(); @@ -611,36 +670,40 @@ export class MasterGameEngine { * ツモ和了 * @param house */ - public commit_tsumoHora(house: House) { + public commit_tsumoHora(house: House, doLog = true) { const tx = this.startTransaction(); if (tx.$state.turn !== house) throw new Error('Not your turn'); - const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ - house: house, + const yakus = calcYakusWithDetail({ + seatWind: house, handTiles: tx.handTileTypes[house], - huros: tx.$state.huros[house], + huros: tx.$state.huros[house].map(convertHuroForCalcYaku), tsumoTile: tx.handTileTypes[house].at(-1)!, ronTile: null, + firstTurn: tx.$state.firstTurnFlags[house], riichi: tx.$state.riichis[house], + doubleRiichi: tx.$state.doubleRiichis[house], ippatsu: tx.$state.ippatsus[house], - })); + rinshan: tx.$state.rinshanFlags[house], + haitei: tx.$state.tiles.length == 0, + }); const doraCount = Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) + Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]); - const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; - const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans); + const pointDeltas = Common.calcTsumoHoraPointDeltas(house, yakus); tx.$state.points.e += pointDeltas.e; tx.$state.points.s += pointDeltas.s; tx.$state.points.w += pointDeltas.w; tx.$state.points.n += pointDeltas.n; - console.log('yakus', house, yakus); + if (doLog) console.log('yakus', house, yakus); tx.$commit(); return { handTiles: tx.$state.handTiles[house], tsumoTile: tx.$state.handTiles[house].at(-1)!, + yakus, }; } @@ -649,7 +712,7 @@ export class MasterGameEngine { cii: false | 'x__' | '_x_' | '__x'; kan: boolean; ron: House[]; - }) { + }, doLog = true) { const tx = this.startTransaction(); if (tx.$state.askings.pon == null && tx.$state.askings.cii == null && tx.$state.askings.kan == null && tx.$state.askings.ron == null) throw new Error(); @@ -668,26 +731,31 @@ export class MasterGameEngine { const callers = answers.ron; const callee = ron.callee; - for (const house of callers) { - const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ - house: house, - handTiles: tx.handTileTypes[house], - huros: tx.$state.huros[house], + const yakus: { [K in House]?: YakuSet } = Object.fromEntries(callers.map(house => { + const ronTile = tx.hoTileTypes[callee].at(-1)!; + const yakus = calcYakusWithDetail({ + seatWind: house, + handTiles: tx.handTileTypes[house].concat([ronTile]), + huros: tx.$state.huros[house].map(convertHuroForCalcYaku), tsumoTile: null, - ronTile: tx.hoTileTypes[callee].at(-1)!, + ronTile, + firstTurn: tx.$state.firstTurnFlags[house], riichi: tx.$state.riichis[house], + doubleRiichi: tx.$state.doubleRiichis[house], ippatsu: tx.$state.ippatsus[house], - })); + hotei: tx.$state.tiles.length == 0, + }); const doraCount = Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) + Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]); - const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; - const point = Common.fanToPoint(fans, house === 'e'); + const point = Common.calcPoint(yakus, house === 'e'); tx.$state.points[callee] -= point; tx.$state.points[house] += point; - console.log('fans point', fans, point); - console.log('yakus', house, yakus); - } + if (doLog) { + console.log('yakus', house, yakus); + } + return [house, yakus] as const; + })); tx.$commit(); @@ -696,6 +764,7 @@ export class MasterGameEngine { callers: ron.callers, callee: ron.callee, turn: null, + yakus, }; } else if (kan != null && answers.kan) { // 大明槓 @@ -712,17 +781,14 @@ export class MasterGameEngine { tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t2), 1); tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t3), 1); - const tiles = [tile, t1, t2, t3]; + const tiles = [tile, t1, t2, t3] as const; tx.$state.huros[kan.caller].push({ type: 'minkan', tiles: tiles, from: kan.callee }); - tx.$state.ippatsus.e = false; - tx.$state.ippatsus.s = false; - tx.$state.ippatsus.w = false; - tx.$state.ippatsus.n = false; + tx.clearFirstTurnAndIppatsus(); tx.$state.activatedDorasCount++; - const rinsyan = tx.tsumo(); + const rinsyan = tx.rinshanTsumo(); tx.$state.turn = kan.caller; @@ -746,13 +812,10 @@ export class MasterGameEngine { tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t1), 1); tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t2), 1); - const tiles = [tile, t1, t2]; + const tiles = [tile, t1, t2] as const; tx.$state.huros[pon.caller].push({ type: 'pon', tiles: tiles, from: pon.callee }); - tx.$state.ippatsus.e = false; - tx.$state.ippatsus.s = false; - tx.$state.ippatsus.w = false; - tx.$state.ippatsus.n = false; + tx.clearFirstTurnAndIppatsus(); tx.$state.turn = pon.caller; @@ -816,10 +879,7 @@ export class MasterGameEngine { tx.$state.huros[cii.caller].push({ type: 'cii', tiles: tiles, from: cii.callee }); - tx.$state.ippatsus.e = false; - tx.$state.ippatsus.s = false; - tx.$state.ippatsus.w = false; - tx.$state.ippatsus.n = false; + tx.clearFirstTurnAndIppatsus(); tx.$state.turn = cii.caller; @@ -891,18 +951,36 @@ export class MasterGameEngine { w: this.$state.huros.w, n: this.$state.huros.n, }, + firstTurnFlags: { + e: this.$state.firstTurnFlags.e, + s: this.$state.firstTurnFlags.s, + w: this.$state.firstTurnFlags.w, + n: this.$state.firstTurnFlags.n, + }, riichis: { e: this.$state.riichis.e, s: this.$state.riichis.s, w: this.$state.riichis.w, n: this.$state.riichis.n, }, + doubleRiichis: { + e: this.$state.doubleRiichis.e, + s: this.$state.doubleRiichis.s, + w: this.$state.doubleRiichis.w, + n: this.$state.doubleRiichis.n, + }, ippatsus: { e: this.$state.ippatsus.e, s: this.$state.ippatsus.s, w: this.$state.ippatsus.w, n: this.$state.ippatsus.n, }, + rinshanFlags: { + e: this.$state.rinshanFlags.e, + s: this.$state.rinshanFlags.s, + w: this.$state.rinshanFlags.w, + n: this.$state.rinshanFlags.n, + }, points: { e: this.$state.points.e, s: this.$state.points.s, @@ -911,6 +989,10 @@ export class MasterGameEngine { }, latestDahaiedTile: null, turn: this.$state.turn, + canPon: null, + canCii: null, + canKan: null, + canRon: null, }; } diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts index c16d81b046..092b10ef24 100644 --- a/packages/misskey-mahjong/src/engine.player.ts +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -6,7 +6,7 @@ import CRC32 from 'crc-32'; import { TileType, House, Huro, TileId } from './common.js'; import * as Common from './common.js'; -import { YAKU_DEFINITIONS } from './common.yaku.js'; +import { calcYakusWithDetail, convertHuroForCalcYaku } from './common.yaku.js'; //#region syntax suger function $(tid: TileId): Common.TileInstance { @@ -53,18 +53,36 @@ export type PlayerState = { w: Huro[]; n: Huro[]; }; + firstTurnFlags: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; riichis: { e: boolean; s: boolean; w: boolean; n: boolean; }; + doubleRiichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; ippatsus: { e: boolean; s: boolean; w: boolean; n: boolean; }; + rinshanFlags: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + } points: { e: number; s: number; @@ -80,7 +98,7 @@ export type PlayerState = { }; export type KyokuResult = { - yakus: { name: string; fan: number; isYakuman: boolean; }[]; + yakus: { name: string; fan: number | null; isYakuman: boolean; }[]; doraCount: number; pointDeltas: { e: number; s: number; w: number; n: number; }; }; @@ -241,31 +259,30 @@ export class PlayerGameEngine { public commit_tsumoHora(house: House, handTiles: TileId[], tsumoTile: TileId): KyokuResult { console.log('commit_tsumoHora', this.state.turn, house); - const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ - house: house, + const yakus = calcYakusWithDetail({ + seatWind: house, handTiles: handTiles.map(id => $type(id)), - huros: this.state.huros[house], + huros: this.state.huros[house].map(convertHuroForCalcYaku), tsumoTile: $type(tsumoTile), ronTile: null, + firstTurn: this.state.firstTurnFlags[house], riichi: this.state.riichis[house], + doubleRiichi: this.state.doubleRiichis[house], ippatsu: this.state.ippatsus[house], - })); + rinshan: this.state.rinshanFlags[house], + haitei: this.state.tilesCount == 0, + }); const doraCount = Common.calcOwnedDoraCount(handTiles.map(id => $type(id)), this.state.huros[house], this.doras) + Common.calcRedDoraCount(handTiles, this.state.huros[house]); - const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; - const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans); + const pointDeltas = Common.calcTsumoHoraPointDeltas(house, yakus); this.state.points.e += pointDeltas.e; this.state.points.s += pointDeltas.s; this.state.points.w += pointDeltas.w; this.state.points.n += pointDeltas.n; return { - yakus: yakus.map(yaku => ({ - name: yaku.name, - fan: yaku.fan, - isYakuman: yaku.isYakuman, - })), + yakus: yakus.yakus, doraCount, pointDeltas, }; @@ -293,24 +310,27 @@ export class PlayerGameEngine { n: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, }; + const ronTile = $type(this.state.hoTiles[callee].at(-1)!); for (const house of callers) { - const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ - house: house, - handTiles: handTiles[house].map(id => $type(id)), - huros: this.state.huros[house], + const yakus = calcYakusWithDetail({ + seatWind: house, + handTiles: handTiles[house].map(id => $type(id)).concat([ronTile]), + huros: this.state.huros[house].map(convertHuroForCalcYaku), tsumoTile: null, - ronTile: $type(this.state.hoTiles[callee].at(-1)!), + ronTile: ronTile, + firstTurn: this.state.firstTurnFlags[house], riichi: this.state.riichis[house], + doubleRiichi: this.state.doubleRiichis[house], ippatsu: this.state.ippatsus[house], - })); + hotei: this.state.tilesCount == 0, + }); const doraCount = Common.calcOwnedDoraCount(handTiles[house].map(id => $type(id)), this.state.huros[house], this.doras) + Common.calcRedDoraCount(handTiles[house], this.state.huros[house]); - const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; - const point = Common.fanToPoint(fans, house === 'e'); + const point = Common.calcPoint(yakus, house === 'e'); this.state.points[callee] -= point; this.state.points[house] += point; - resultMap[house].yakus = yakus.map(yaku => ({ name: yaku.name, fan: yaku.fan, isYakuman: yaku.isYakuman })); + resultMap[house].yakus = yakus.yakus; resultMap[house].doraCount = doraCount; resultMap[house].pointDeltas[callee] = -point; resultMap[house].pointDeltas[house] = point; @@ -329,7 +349,7 @@ export class PlayerGameEngine { * @param caller ポンした人 * @param callee 牌を捨てた人 */ - public commit_pon(caller: House, callee: House, tiles: TileId[]) { + public commit_pon(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId]) { this.state.canPon = null; this.state.hoTiles[callee].pop(); @@ -351,7 +371,7 @@ export class PlayerGameEngine { * @param caller 大明槓した人 * @param callee 牌を捨てた人 */ - public commit_kan(caller: House, callee: House, tiles: TileId[], rinsyan: TileId) { + public commit_kan(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId, TileId], rinsyan: TileId) { this.state.canKan = null; this.state.hoTiles[callee].pop(); @@ -383,7 +403,7 @@ export class PlayerGameEngine { * @param caller チーした人 * @param callee 牌を捨てた人 */ - public commit_cii(caller: House, callee: House, tiles: TileId[]) { + public commit_cii(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId]) { this.state.canCii = null; this.state.hoTiles[callee].pop(); diff --git a/packages/misskey-mahjong/test/engine.ts b/packages/misskey-mahjong/test/engine.ts new file mode 100644 index 0000000000..329d19ff9d --- /dev/null +++ b/packages/misskey-mahjong/test/engine.ts @@ -0,0 +1,235 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import * as Common from '../src/common.js'; +import { TileType, TileId } from '../src/common.js'; +import { MasterGameEngine, MasterState, INITIAL_POINT } from '../src/engine.master.js'; + +const TILES = [71, 132, 108, 51, 39, 19, 3, 86, 104, 18, 50, 7, 45, 82, 43, 34, 111, 78, 53, 105, 126, 91, 112, 75, 119, 55, 95, 93, 65, 9, 66, 52, 79, 32, 99, 109, 56, 5, 101, 92, 1, 37, 62, 23, 27, 117, 77, 14, 31, 96, 120, 130, 29, 135, 100, 17, 102, 124, 59, 89, 49, 115, 107, 97, 90, 48, 25, 110, 68, 15, 74, 129, 69, 61, 73, 81, 11, 41, 44, 84, 13, 40, 33, 58, 30, 8, 38, 10, 87, 125, 57, 121, 21, 2, 54, 46, 22, 4, 133, 16, 76, 70, 60, 103, 114, 122, 24, 88, 36, 123, 47, 12, 128, 118, 116, 63, 26, 94, 67, 131, 64, 35, 113, 134, 6, 127, 80, 72, 42, 98, 85, 20, 106, 136, 83, 28]; + +const INITIAL_TILES_LENGTH = 69; + +class TileSetBuilder { + private restTiles = [...TILES]; + + private handTiles: { + e: TileId[] | null, + s: TileId[] | null, + w: TileId[] | null, + n: TileId[] | null, + } = { + e: null, + s: null, + w: null, + n: null, + }; + + private tiles = new Map; + + public setHandTiles(house: Common.House, tileTypes: TileType[]): this { + if (this.handTiles[house] != null) { + throw new TypeError(`Hand tiles of house '${house}' is already set`); + } + + const tiles = tileTypes.map(tile => { + const index = this.restTiles.findIndex(tileId => Common.TILE_ID_MAP.get(tileId)!.t == tile); + if (index == -1) { + throw new TypeError(`Tile '${tile}' is not left`); + } + return this.restTiles.splice(index, 1)[0]; + }); + + this.handTiles[house] = tiles; + + return this; + } + + /** + * 山のn番目(0始まり)の牌を指定する。nが負の場合、海底を-1として海底側から数える + */ + public setTile(n: number, tileType: TileType): this { + if (n < 0) { + n += INITIAL_TILES_LENGTH; + } + + if (n < 0 || n >= INITIAL_TILES_LENGTH) { + throw new RangeError(`Cannot set ${n}th tile`); + } + + const indexInTiles = INITIAL_TILES_LENGTH - n - 1; + + if (this.tiles.has(indexInTiles)) { + throw new TypeError(`${n}th tile is already set`); + } + + const indexInRestTiles = this.restTiles.findIndex(tileId => Common.TILE_ID_MAP.get(tileId)!.t == tileType); + if (indexInRestTiles == -1) { + throw new TypeError(`Tile '${tileType}' is not left`); + } + this.tiles.set(indexInTiles, this.restTiles.splice(indexInRestTiles, 1)[0]); + + return this; + } + + public build(): Pick { + const handTiles: MasterState['handTiles'] = { + e: this.handTiles.e ?? this.restTiles.splice(0, 14), + s: this.handTiles.s ?? this.restTiles.splice(0, 13), + w: this.handTiles.w ?? this.restTiles.splice(0, 13), + n: this.handTiles.n ?? this.restTiles.splice(0, 13), + }; + + const kingTiles: MasterState['kingTiles'] = this.restTiles.splice(0, 14); + + const tiles = [...this.restTiles]; + for (const [index, tile] of [...this.tiles.entries()].sort(([index1], [index2]) => index1 - index2)) { + tiles.splice(index, 0, tile); + } + + return { + tiles, + kingTiles, + handTiles, + }; + } +} + +function tsumogiri(engine: MasterGameEngine, riichi = false): void { + const house = engine.turn; + if (house == null) { + throw new Error('No one\'s turn'); + } + engine.commit_dahai(house, engine.handTiles[house].at(-1)!, riichi); +} + +function tsumogiriAndIgnore(engine: MasterGameEngine, riichi = false): void { + tsumogiri(engine, riichi); + if (engine.askings.pon != null || engine.askings.cii != null || engine.askings.kan != null || engine.askings.ron != null) { + engine.commit_resolveCallingInterruption({ + pon: false, + cii: false, + kan: false, + ron: [], + }); + } +} + +describe('Master game engine', () => { + it('tenho', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder().setHandTiles('e', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3']).build(), + )); + expect(engine.commit_tsumoHora('e', false).yakus.yakuNames).toEqual(['tenho']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT + 48000, + s: INITIAL_POINT - 16000, + w: INITIAL_POINT - 16000, + n: INITIAL_POINT - 16000, + }); + }); + + it('chiho', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('s', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3']) + .setTile(0, 'm3') + .build(), + )); + tsumogiriAndIgnore(engine); + expect(engine.commit_tsumoHora('s', false).yakus.yakuNames).toEqual(['chiho']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT - 16000, + s: INITIAL_POINT + 32000, + w: INITIAL_POINT - 8000, + n: INITIAL_POINT - 8000, + }); + }); + + it('rinshan', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('e', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'n']) + .setTile(-1, 'm3') + .build(), + )); + engine.commit_ankan('e', engine.$state.handTiles.e.at(-1)!); + expect(engine.commit_tsumoHora('e', false).yakus.yakuNames).toEqual(['tsumo', 'rinshan']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT + 3000, + s: INITIAL_POINT - 1000, + w: INITIAL_POINT - 1000, + n: INITIAL_POINT - 1000, + }); + }); + + it('double-riichi ippatsu tsumo', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('e', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 's']) + .setTile(3, 'm3') + .build(), + )); + tsumogiriAndIgnore(engine, true); + tsumogiriAndIgnore(engine); + tsumogiriAndIgnore(engine); + tsumogiriAndIgnore(engine); + expect(engine.commit_tsumoHora('e', false).yakus.yakuNames).toEqual(['tsumo', 'double-riichi', 'ippatsu']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT + 12000, + s: INITIAL_POINT - 4000, + w: INITIAL_POINT - 4000, + n: INITIAL_POINT - 4000, + }); + }); + + it('double-riichi haitei tsumo', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('s', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3']) + .setTile(-1, 'm3') + .build(), + )); + tsumogiriAndIgnore(engine); + tsumogiriAndIgnore(engine, true); + while (engine.$state.tiles.length > 0) { + tsumogiriAndIgnore(engine); + } + expect(engine.commit_tsumoHora('s', false).yakus.yakuNames).toEqual(['tsumo', 'double-riichi', 'haitei']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT - 4000, + s: INITIAL_POINT + 8000, + w: INITIAL_POINT - 2000, + n: INITIAL_POINT - 2000, + }); + }); + + it('double-riichi hotei', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('e', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 's']) + .setHandTiles('s', ['m3', 'm6', 'p2', 'p5', 'p8', 's4', 'e', 's', 'w', 'haku', 'hatsu', 'chun', 'chun']) + .setTile(-1, 'm3') + .build(), + )); + tsumogiriAndIgnore(engine, true); + while (engine.$state.tiles.length > 0) { + tsumogiriAndIgnore(engine); + } + tsumogiri(engine); + expect(engine.commit_resolveCallingInterruption({ + pon: false, + cii: false, + kan: false, + ron: ['e'], + }, false).yakus?.e?.yakuNames).toEqual(['double-riichi', 'hotei']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT + 6000, + s: INITIAL_POINT - 6000, + w: INITIAL_POINT, + n: INITIAL_POINT, + }); + }); +}); diff --git a/packages/misskey-mahjong/test/fu.ts b/packages/misskey-mahjong/test/fu.ts new file mode 100644 index 0000000000..7ec1ce3581 --- /dev/null +++ b/packages/misskey-mahjong/test/fu.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from "node:assert" +import { calcWaitPatterns } from "../src/common.fu" +import { analyzeFourMentsuOneJyantou } from "../src/common" + +describe('Fu', () => { + describe('Wait patterns', () => { + it('Ryanmen', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's1', 's1', 's7', 's8', 's9'] + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 's9'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'mentsu', + agariTile: 's9', + waitedTaatsu: ['s7', 's8'], + }]); + }); + + it('Kanchan', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's1', 's1', 's7', 's8', 's9'] + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 's8'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'mentsu', + agariTile: 's8', + waitedTaatsu: ['s7', 's9'], + }]); + }) + + it('Penchan', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's1', 's1', 's7', 's8', 's9'] + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 's7'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'mentsu', + agariTile: 's7', + waitedTaatsu: ['s8', 's9'], + }]); + }) + + it('Tanki', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'] + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 'e'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'head', + agariTile: 'e', + }]); + }); + + it('Nobetan', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m1', 'm2', 'm3', 'm5', 'm6', 'm7', 'p2', 'p3', 'p4', 's3', 's4', 's5', 's6', 's6'] + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 's6'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'head', + agariTile: 's6', + }]); + }); + }); +}); diff --git a/packages/misskey-mahjong/test/yaku.ts b/packages/misskey-mahjong/test/yaku.ts index dc184d2fb8..cdf3230b7e 100644 --- a/packages/misskey-mahjong/test/yaku.ts +++ b/packages/misskey-mahjong/test/yaku.ts @@ -10,26 +10,68 @@ describe('Yaku', () => { describe('Riichi', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], huros: [], + ronTile: 'm3', riichi: true, }), ['riichi']); }); }); + describe('double-riichi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + ronTile: 'm3', + riichi: true, + doubleRiichi: true, + }), ['double-riichi']); + }); + }); + + describe('tsumo', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + riichi: false, + }), ['tsumo']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm2', 'm2', 'm8', 'm8', 'p5', 'p5', 'p7', 'p7', 's9', 's9', 'p2', 'p2'], + huros: [], + tsumoTile: 'p2', + }).includes('tsumo'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm2', 'm2', 'm8', 'm8', 'p5', 'p5', 'p7', 'p7', 's9', 's9', 'p2', 'p2'], + huros: [], + ronTile: 'p2', + }).includes('tsumo'), false); + }) + }); + describe('white', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], - huros: [{type: 'pon', tile: 'haku'}], + huros: [{type: 'ankan', tile: 'haku'}], tsumoTile: 'm3', riichi: true, }).includes('white'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], huros: [{type: 'pon', tile: 'haku'}], tsumoTile: 'm3', @@ -41,15 +83,15 @@ describe('Yaku', () => { describe('red', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], - huros: [{type: 'pon', tile: 'chun'}], + huros: [{type: 'ankan', tile: 'chun'}], tsumoTile: 'm3', riichi: true, }).includes('red'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], huros: [{type: 'pon', tile: 'chun'}], tsumoTile: 'm3', @@ -61,15 +103,15 @@ describe('Yaku', () => { describe('green', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], - huros: [{type: 'pon', tile: 'hatsu'}], + huros: [{type: 'ankan', tile: 'hatsu'}], tsumoTile: 'm3', riichi: true, }).includes('green'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], huros: [{type: 'pon', tile: 'hatsu'}], tsumoTile: 'm3', @@ -81,17 +123,17 @@ describe('Yaku', () => { describe('field-wind', () => { it('north', () => { assert.deepStrictEqual(calcYakus({ - house: 'n', - seat: 'e', + fieldWind: 'n', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'n', 'n', 'n'], huros: [], - tsumoTile: 's', + tsumoTile: 'n', }).includes('field-wind-n'), true); }); it('east', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - seat: 'n', + fieldWind: 'e', + seatWind: 'n', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'e', 'e', 'e'], huros: [], tsumoTile: 'e', @@ -99,8 +141,7 @@ describe('Yaku', () => { }); it('south', () => { assert.deepStrictEqual(calcYakus({ - house: 's', - house: 'n', + fieldWind: 's', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 's', 's', 's'], huros: [], tsumoTile: 's', @@ -108,8 +149,7 @@ describe('Yaku', () => { }); it('west', () => { assert.deepStrictEqual(calcYakus({ - house: 'w', - house: 'n', + fieldWind: 'w', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'w', 'w', 'w'], huros: [], tsumoTile: 'w', @@ -119,17 +159,17 @@ describe('Yaku', () => { describe('seat-wind', () => { it('north', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - seat: 'n', + fieldWind: 'e', + seatWind: 'n', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'n', 'n', 'n'], huros: [], - ronTile: 's', + ronTile: 'n', }).includes('seat-wind-n'), true); }); it('east', () => { assert.deepStrictEqual(calcYakus({ - house: 's', - seat: 'e', + fieldWind: 's', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'e', 'e', 'e'], huros: [], ronTile: 'e', @@ -137,17 +177,15 @@ describe('Yaku', () => { }); it('south', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - house: 's', + seatWind: 's', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 's', 's', 's'], huros: [], - ronoTile: 's', + ronTile: 's', }).includes('seat-wind-s'), true); }); it('west', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - house: 'w', + seatWind: 'w', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'w', 'w', 'w'], huros: [], ronTile: 'w', @@ -158,7 +196,7 @@ describe('Yaku', () => { describe('ippatsu', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], huros: [], riichi: true, @@ -167,7 +205,7 @@ describe('Yaku', () => { }).includes('ippatsu'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], huros: [], riichi: true, @@ -177,7 +215,7 @@ describe('Yaku', () => { }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], huros: [], tsumoTile: 'm3', @@ -186,29 +224,108 @@ describe('Yaku', () => { }); }); + describe('rinshan', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'n', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ + type: 'ankan', + tile: 'n' + }], + tsumoTile: 'm3', + rinshan: true, + }).includes('rinshan'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'n', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ + type: 'ankan', + tile: 'n' + }], + tsumoTile: 'm3', + }).includes('rinshan'), false); + }); + }); + + describe('haitei', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + haitei: true, + }).includes('haitei'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + haitei: false, + }).includes('haitei'), false); + }); + }); + + describe('hotei', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + ronTile: 'm3', + hotei: true, + }).includes('hotei'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + ronTile: 'm3', + hotei: false, + }).includes('hotei'), false); + }); + }); + describe('tanyao', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm2', 'm2', 'p6', 'p7', 'p8', 's3', 's3', 's3', 's4', 's5', 's6', 'm3', 'm3'], huros: [], tsumoTile: 'm3', }).includes('tanyao'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['p6', 'p7', 'p8', 's3', 's3', 's3', 's4', 's5', 's6', 'm3', 'm3'], huros: [{type: 'pon', tile: 'm2'}], tsumoTile: 'm3', }).includes('tanyao'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm2', 'm3', 'm3', 'm8', 'm8', 'p5', 'p5', 'p7', 'p7', 's8', 's8', 'p2', 'p2'], + huros: [], + tsumoTile: 'p2', + }).includes('tanyao'), true); }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p6', 'p7', 'p8', 's3', 's3', 's3', 's4', 's5', 's6', 'm3', 'm3'], ippatsu: true, huros: [], + tsumoTile: 'm1', }).includes('tanyao'), false); }); }); @@ -216,48 +333,48 @@ describe('Yaku', () => { describe('pinfu', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's9', 's9', 's5', 's6', 's7'], huros: [], tsumoTile: 's7', }).includes('pinfu'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['m2', 'm3', 'm4', 'p6', 'p6', 'p6', 'p5', 'p6', 'p7', 's9', 's9', 's7', 's8', 's9'], + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's9', 's9', 's5', 's6', 's7'], huros: [], - tsumoTile: 's9', - }).includes('pinfu'), false); + ronTile: 's7', + }).includes('pinfu'), true); }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 'p6', 'p6', 'p6', 'p5', 'p6', 'p7', 's9', 's9', 's5', 's6', 's7'], huros: [], tsumoTile: 's7', }).includes('pinfu'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 'p6', 'p6', 'p6', 'p5', 'p6', 'p7', 's9', 's9', 's7', 's8', 's9'], huros: [], tsumoTile: 's7', }).includes('pinfu'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's9', 's9', 's5', 's6', 's7'], huros: [], - ronTile: 's7', - }).includes('pinfu'), true); + ronTile: 's6', + }).includes('pinfu'), false); }); }); describe('iipeko', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 'm2', 'm3', 'm4', 'p5', 'p6', 'p7', 's9', 's9', 's4', 's5', 's6'], huros: [], tsumoTile: 's6', @@ -266,15 +383,14 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 'p5', 'p6', 'p7', 's9', 's9', 's4', 's5', 's6'], huros: [{type: 'cii', tiles: ['m2','m3','m4']}], - riichi: true, tsumoTile: 's6', }).includes('iipeko'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 'm2', 'm3', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], huros: [], tsumoTile: 'p1', @@ -284,26 +400,26 @@ describe('Yaku', () => { describe('ryanpeko', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 'm2', 'm3', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], huros: [], tsumoTile: 'p1', - }).includes('ryanpeko'), true); + }).includes('ryampeko'), true); }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['m2', 'm3', 'm4', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], huros: [{type: 'cii', tiles: ['m2','m3','m4']}], tsumoTile: 'p1', - }).includes('ryanpeko'), true); + }).includes('ryampeko'), false); }); }); describe('sanshoku-dojun', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 'p1', 'p2', 'p3', 's1', 's2', 's3', 'n', 'n', 'n', 'm3', 'm3'], huros: [], tsumoTile: 'm3', @@ -315,8 +431,8 @@ describe('Yaku', () => { describe('sanshoku-doko', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['m2', 'm2', 'm2', 'p2', 'p2', 'p2', 's2', 's2', 's2', 'n', 'n', 'n', 'm3', 'm3'], + seatWind: 'e', + handTiles: ['m2', 'm2', 'm2', 'p2', 'p2', 'p2', 's2', 's2', 's2', 's4', 's5', 's6', 'm3', 'm3'], huros: [], tsumoTile: 'm3', }).includes('sanshoku-doko'), true); @@ -327,14 +443,14 @@ describe('Yaku', () => { describe('ittsu', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9', 'm3', 'm3'], huros: [], tsumoTile: 'm3', }).includes('ittsu'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm3', 'm4', 's4', 's5', 's6', 's7', 's8', 's9', 'm3', 'm3'], huros: [{type: 'cii', tiles:['s1', 's2', 's3']}], tsumoTile: 'm3', @@ -345,14 +461,14 @@ describe('Yaku', () => { describe('chanta', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 's1', 's2', 's3', 's7', 's8', 's9', 'p7', 'p8', 'p9', 'haku', 'haku'], huros: [], tsumoTile: 'haku', }).includes('chanta'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 's1', 's2', 's3', 's7', 's8', 's9', 'haku', 'haku'], huros: [{type: 'pon', tile : 'p9'}], tsumoTile: 'haku', @@ -363,14 +479,14 @@ describe('Yaku', () => { describe('junchan', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 's1', 's2', 's3', 's7', 's8', 's9', 'p7', 'p8', 'p9', 'm9', 'm9'], huros: [], tsumoTile: 'm9', }).includes('junchan'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm2', 'm3', 's1', 's2', 's3', 's7', 's8', 's9', 'm9', 'm9'], huros: [{type: 'pon', tile : 'p9'}], tsumoTile: 'm9', @@ -381,24 +497,32 @@ describe('Yaku', () => { describe('chitoitsu', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['m1', 'm1', 'm2', 'm2', 'm8', 'm8', 'p5', 'p5', 'p7', 's7', 's9', 's9', 'p2', 'p2'], + seatWind: 'e', + handTiles: ['m1', 'm1', 'm2', 'm2', 'm8', 'm8', 'p5', 'p5', 'p7', 'p7', 's9', 's9', 'p2', 'p2'], huros: [], tsumoTile: 'p2', }).includes('chitoitsu'), true); }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + handTiles: ['m2', 'm3', 'm4', 'm2', 'm3', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], + huros: [], + tsumoTile: 'p1', + }).includes('chitoitsu'), false) + }) }); describe('toitoi', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'p5', 'p5', 'p5', 's7', 's7', 's7', 'p2', 'p2'], huros: [], - tsumoTile: 'p2', + ronTile: 's7', }).includes('toitoi'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm2', 'm2', 'p5', 'p5', 'p5', 's7', 's7', 's7', 'p2', 'p2'], huros: [{type: 'pon', tile: 'm1'}], tsumoTile: 'p2', @@ -409,53 +533,59 @@ describe('Yaku', () => { describe('sananko', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'p5', 'p5', 'p5', 's7', 's8', 's9', 'p2', 'p2'], huros: [], tsumoTile: 'p2', }).includes('sananko'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['m1', 'm1', 'm1', 'p5', 'p5', 'p5', 's7', 's7', 's7', 'p2', 'p2'], + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'p5', 'p5', 'p5', 's7', 's8', 's9', 'p2', 'p2'], huros: [{type: 'ankan', tile: 'm2'}], tsumoTile: 'p2', }).includes('sananko'), true); }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'p5', 'p5', 'p5', 's7', 's8', 's9', 'p2', 'p2'], huros: [{type: 'minkan', tile: 'm2'}], tsumoTile: 'p2', }).includes('sananko'), false); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'p5', 'p5', 'p5', 's7', 's8', 's9', 'p2', 'p2'], + huros: [], + ronTile: 'm2', + }).includes('sananko'), false); }); }); describe('honroto', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm9', 'm9', 'm9', 'p9', 'p9', 'p9', 'hatsu', 'hatsu', 'hatsu', 'n', 'n'], huros: [], - tsumoTile: 'n', - }).includes('toitoi'), true); + ronTile: 'hatsu', + }).includes('honroto'), true); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m9', 'm9', 'm9', 'p9', 'p9', 'p9', 'hatsu', 'hatsu', 'hatsu', 'n', 'n'], huros: [{type: 'pon', tile: 'm1'}], - tsumoTile: 'p2', - }).includes('toitoi'), true); + tsumoTile: 'p9', + }).includes('honroto'), true); }); }); describe('sankantsu', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m9', 'm9', 'm9', 'n', 'n'], - huros: [[{type: 'ankan', tile: 'm1'}, {type: 'ankan', tile: 'm2'}, {type: 'minkan', tile: 'm3'}]], - tsumoTile: 'p2', + huros: [{type: 'ankan', tile: 'm1'}, {type: 'ankan', tile: 'm2'}, {type: 'minkan', tile: 'm3'}], + tsumoTile: 'm9', }).includes('sankantsu'), true); }); }); @@ -463,37 +593,52 @@ describe('Yaku', () => { describe('honitsu', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm5', 'm6', 'm7', 'm9', 'm9', 'm9', 'n', 'n'], + huros: [{type: 'pon', tile: 'w'}], + tsumoTile: 'n', + }).includes('honitsu'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm5', 'm5', 'm6', 'm6', 'm7', 'm7', 'm9', 'm9', 'w', 'w', 'n', 'n'], huros: [], tsumoTile: 'n', }).includes('honitsu'), true); }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm5', 'm6', 'm7', 'm9', 'm9', 'm9', 'm8', 'm8'], - huros: [], + huros: [{type: 'pon', tile: 'm3'}], tsumoTile: 'm8', }).includes('honitsu'), false); }); }); describe('chinitsu', () => { - it('invalid', () => { + it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm5', 'm6', 'm7', 'm9', 'm9', 'm9', 'm8', 'm8'], - huros: [], + huros: [{type: 'pon', tile: 'm3'}], tsumoTile: 'm8', }).includes('chinitsu'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm2', 'm2', 'm4', 'm4', 'm5', 'm5', 'm6', 'm6', 'm7', 'm7', 'm9', 'm9'], + huros: [], + tsumoTile: 'm9', + }).includes('chinitsu'), true); }); }); + describe('shosangen', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['haku', 'haku', 'haku', 'chun', 'chun', 'hatsu', 'hatsu', 'hatsu', 'm1', 'm1', 'm1', 'm2', 'm2', 'm2'] , + seatWind: 'e', + handTiles: ['haku', 'haku', 'haku', 'chun', 'chun', 'hatsu', 'hatsu', 'hatsu', 'm1', 'm1', 'm1', 'm2', 'm3', 'm4'] , huros: [], tsumoTile: 'm2', }).includes('shosangen'), true); @@ -503,7 +648,7 @@ describe('Yaku', () => { describe('kokushi', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm9'] , huros: [], tsumoTile: 'm9', @@ -512,7 +657,7 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm9', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm3'] , huros: [], tsumoTile: 'm3', @@ -523,7 +668,7 @@ describe('Yaku', () => { describe('kokushi-13', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm9', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm1'] , huros: [], tsumoTile: 'm1', @@ -532,8 +677,8 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['m1', 'm1', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm9'] , + seatWind: 'e', + handTiles: ['m1', 'm9', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm9'] , huros: [], tsumoTile: 'm1', }).includes('kokushi-13'), false); @@ -543,13 +688,13 @@ describe('Yaku', () => { describe('suanko', () => { it('valid',() => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'e', 'e'], huros: [], tsumoTile: 'chun', }), ['suanko']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm2', 'm2', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'e', 'e'], huros: [{type: 'ankan', tile: 'm1'}], tsumoTile: 'chun', @@ -558,21 +703,21 @@ describe('Yaku', () => { it('invalid',() => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'm2', 'm2', 'e', 'e', 'e'], huros: [{type: 'pon', tile: 'm1'}], ronTile: 'e', }).includes('suanko'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'm2', 'm2', 'e', 'e', 'e'], huros: [], ronTile: 'e', }).includes('suanko'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'm2', 'm2', 'e', 'e', 'e'], huros: [], ronTile: 'e', @@ -583,22 +728,28 @@ describe('Yaku', () => { describe('suanko-tanki', () => { it('valid', () =>{ assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], huros: [], tsumoTile: 'e', }), ['suanko-tanki']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], huros: [{type: 'ankan', tile: 'm1'}], tsumoTile: 'e', }), ['suanko-tanki']); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], + huros: [], + ronTile: 'e', + }), ['suanko-tanki']); }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'e', 'e'], huros: [], tsumoTile: 'chun', @@ -609,14 +760,14 @@ describe('Yaku', () => { describe('daisangen', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['haku', 'haku', 'haku', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'p2', 'p2', 'p2', 's2', 's2'], + seatWind: 'e', + handTiles: ['haku', 'haku', 'haku', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'p2', 'p3', 'p4', 's2', 's2'], huros: [], tsumoTile: 's2', }), ['daisangen']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'p2', 'p2', 'p2', 's2', 's2'], huros: [{type: 'pon', tile: 'haku'}], tsumoTile: 's2', @@ -625,7 +776,7 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['haku', 'haku', 'haku', 'chun', 'chun', 'hatsu', 'hatsu', 'hatsu', 'm1', 'm1', 'm1', 'm2', 'm2', 'm2'] , huros: [], tsumoTile: 'm2', @@ -636,46 +787,54 @@ describe('Yaku', () => { describe('tsuiso', () => { it('valid', () =>{ assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['haku', 'haku', 'haku', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [], tsumoTile: 's', - }), ['tsuiso']); + }), [ 'suanko-tanki', 'tsuiso']); + assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [{type: 'pon', tile: 'haku'}], tsumoTile: 's', }), ['tsuiso']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['e', 'e', 's', 's', 'w', 'w', 'n', 'n', 'haku', 'haku', 'hatsu', 'hatsu', 'chun', 'chun'], + huros: [], + tsumoTile: 's', + }), ['tsuiso']); }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [], tsumoTile: 's', }).includes('tsuiso'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [{type: 'pon', tile: 'm1'}], tsumoTile: 's', - }).includes('tuiso'), false); + }).includes('tsuiso'), false); }); }) describe('shosushi', () => { it('valid', () =>{ assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['m1', 'm1', 'm1', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [], tsumoTile: 's', }), ['shosushi']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'n', 'n', 'n', 'w', 'w', 'w', 's', 's'], huros: [{type: 'pon', tile: 'e'}], tsumoTile: 's', @@ -684,14 +843,14 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [], tsumoTile: 's', }).includes('shosushi'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [{type: 'pon', tile: 'm1'}], tsumoTile: 's', @@ -702,29 +861,29 @@ describe('Yaku', () => { describe('daisushi', () => { it('valid', () =>{ assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's', 's'], huros: [], tsumoTile: 's', - }), ['daisushi']); + }), ['suanko', 'daisushi']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'n', 'n', 'n', 'w', 'w', 'w', 's', 's', 's'], huros: [{type: 'pon', tile: 'e'}], - tsumoTile: 'e', + tsumoTile: 's', }), ['daisushi']); }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [], tsumoTile: 's', }).includes('daisushi'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], huros: [{type: 'pon', tile: 'm1'}], tsumoTile: 'e', @@ -735,13 +894,13 @@ describe('Yaku', () => { describe('ryuiso', () => { it('valid', () =>{ assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['s2', 's2', 's2', 's2', 's3', 's4', 's6', 's6', 's6', 's8', 's8', 's8', 'hatsu', 'hatsu'], huros: [], tsumoTile: 'hatsu', }), ['ryuiso']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8'], huros: [{type: 'pon', tile: 'hatsu'}], tsumoTile: 's8', @@ -750,16 +909,16 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8','haku','haku','haku'], huros: [], - tsumoTile: 's', + tsumoTile: 's2', }).includes('ryuiso'), false); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8'], huros: [{type: 'pon', tile: 'haku'}], - tsumoTile: 's', + tsumoTile: 's2', }).includes('ryuiso'), false); }); }) @@ -767,13 +926,13 @@ describe('Yaku', () => { describe('chinroto', () => { it('valid', () =>{ assert.deepStrictEqual(calcYakus({ - house: 'e', - handTiles: ['m1','m1','m1''m9', 'm9', 'm9', 's1', 's1', 's1', 's9', 's9', 's9', 'p1', 'p1'], + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm9', 'm9', 'm9', 's1', 's1', 's1', 's9', 's9', 's9', 'p1', 'p1'], huros: [], tsumoTile: 'p1', - }), ['chinroto']); + }), ['suanko-tanki', 'chinroto']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m9', 'm9', 'm9', 's1', 's1', 's1', 's9', 's9', 's9', 'p1', 'p1'], huros: [{type: 'pon', tile: 'm1'}], tsumoTile: 'p1', @@ -782,10 +941,10 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8','haku','haku','haku'], huros: [], - tsumoTile: 's', + tsumoTile: 's2', }).includes('chinroto'), false); }); }) @@ -793,7 +952,7 @@ describe('Yaku', () => { describe('sukantsu', () => { it('valid', () =>{ assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['p1', 'p1'], huros: [{type: 'ankan', tile: 'm1'}, {type: 'ankan', tile: 'm2'}, {type: 'minkan', tile: 'm3'}, {type: 'minkan', tile: 'chun'}], tsumoTile: 'p1', @@ -801,7 +960,7 @@ describe('Yaku', () => { }); it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm2'], huros: [], tsumoTile: 'm2', @@ -812,7 +971,7 @@ describe('Yaku', () => { describe('churen', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm5'], huros: [], tsumoTile: 'm5', @@ -821,7 +980,7 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm2'], huros: [], tsumoTile: 'm2', @@ -832,63 +991,63 @@ describe('Yaku', () => { describe('churen-9', () => { it('valid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm1'], huros: [], tsumoTile: 'm1', }), ['churen-9']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm2'], huros: [], tsumoTile: 'm2', }), ['churen-9']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm3'], huros: [], tsumoTile: 'm3', }), ['churen-9']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm4'], huros: [], tsumoTile: 'm4', }), ['churen-9']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm5'], huros: [], tsumoTile: 'm5', }), ['churen-9']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm6'], huros: [], tsumoTile: 'm6', }), ['churen-9']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm7'], huros: [], tsumoTile: 'm7', }), ['churen-9']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm8'], huros: [], tsumoTile: 'm8', }), ['churen-9']); assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm9'], huros: [], tsumoTile: 'm9', @@ -897,11 +1056,35 @@ describe('Yaku', () => { it('invalid', () => { assert.deepStrictEqual(calcYakus({ - house: 'e', + seatWind: 'e', handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm5'], huros: [], tsumoTile: 'm5', }).includes('churen-9'), false); }); }); + + describe('tenho', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + firstTurn: true, + }), ['tenho']); + }); + }); + + describe('chiho', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 's', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + firstTurn: true, + }), ['chiho']); + }); + }); });