feature(mahjong): 搶槓/ドラ以外の麻雀の役を実装 (#14346)
* ビルドによる自動的なソース更新
* 麻雀関連のキーバリューペアを追加
* 役の定義をまとめてエクスポート
* タイポ修正
* Revert "麻雀関連のキーバリューペアを追加"
This reverts commit c349cdf70c
.
* misskey-jsのビルドによる自動更新
* 型エラーに対処
* riichiがtrueの場合に門前であるかを確認
* EnvForCalcYakuのhouseプロパティを廃止
* 風牌の役の共通部分をクラスで定義
* タイポ修正
* 役牌をクラスで共通化
* 一盃口と二盃口のテストを通す
* 一盃口・二盃口判定関数の調整
* 一気通貫の判定にチーによる順子も考慮する
* 混全帯幺九の実装
* 純全帯幺九の実装
* 七対子の実装とテストの修正
* tsumoTileまたはronTileを必須に
* 待ちを確認して平和の判定を可能に
* 三暗刻と四暗刻、四暗刻単騎の実装
* 四暗刻であるために通常の役を判定できない牌姿のテストを修正
* 混老頭と清老頭を実装
* 三槓子と四槓子を実装
* 平和の実装とテストを修正
* 小三元のテストを修正
* 国士無双に対子の確認を追加
* 国士無双十三面待ちを実装し、テストを修正
* 一部の役の七対子形を認め、テストを追加
* 手牌の数を確認
* 役の定義をカプセル化して型エラーの対処
* ツモ・ロンの判定を修正
* calcYakusの引数のhandTilesを修正
* calcYakusに渡す風をseatWindに修正
* 嶺上開花の実装
* 海底摸月の実装
* FourMentsuOneJyantouWithWait型の作成
* 河底撈魚の実装
* ダブル立直の実装
* 天和・地和の実装
* エンジンのテストを作成
* エンジンによる地和のテストを追加
* 嶺上開花のテスト
* ライセンスの記述を追加
* ダブル立直一発ツモのテスト
* ダブル立直海底ツモのテスト
* ダブル立直河底のテスト
* 役満も処理できるように
* 点数のテスト
* 打牌時にrinshanFlags[house]をfalseに
* 七対子形の字一色を認める
* typo
This commit is contained in:
parent
f32b11ba12
commit
bf818a6656
63
packages/misskey-mahjong/src/common.fu.ts
Normal file
63
packages/misskey-mahjong/src/common.fu.ts
Normal file
|
@ -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];
|
||||||
|
}
|
|
@ -109,21 +109,29 @@ export type House = 'e' | 's' | 'w' | 'n';
|
||||||
*/
|
*/
|
||||||
export type Huro = {
|
export type Huro = {
|
||||||
type: 'pon';
|
type: 'pon';
|
||||||
tiles: [TileId, TileId, TileId];
|
tiles: readonly [TileId, TileId, TileId];
|
||||||
from: House;
|
from: House;
|
||||||
} | {
|
} | {
|
||||||
type: 'cii';
|
type: 'cii';
|
||||||
tiles: [TileId, TileId, TileId];
|
tiles: readonly [TileId, TileId, TileId];
|
||||||
from: House;
|
from: House;
|
||||||
} | {
|
} | {
|
||||||
type: 'ankan';
|
type: 'ankan';
|
||||||
tiles: [TileId, TileId, TileId, TileId];
|
tiles: readonly [TileId, TileId, TileId, TileId];
|
||||||
} | {
|
} | {
|
||||||
type: 'minkan';
|
type: 'minkan';
|
||||||
tiles: [TileId, TileId, TileId, TileId];
|
tiles: readonly [TileId, TileId, TileId, TileId];
|
||||||
from: House | null; // null で加槓
|
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 CALL_HURO_TYPES = ['pon', 'cii', 'minkan'] as const;
|
||||||
|
|
||||||
export const NEXT_TILE_FOR_DORA_MAP: Record<TileType, TileType> = {
|
export const NEXT_TILE_FOR_DORA_MAP: Record<TileType, TileType> = {
|
||||||
|
@ -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 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 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 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'];
|
const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'];
|
||||||
|
|
||||||
export function isManzu<T extends TileType>(tile: T): tile is typeof MANZU_TILES[number] {
|
export function includes<A extends ReadonlyArray<unknown>>(array: A, searchElement: unknown): searchElement is A[number] {
|
||||||
return MANZU_TILES.includes(tile);
|
return array.includes(searchElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPinzu<T extends TileType>(tile: T): tile is typeof PINZU_TILES[number] {
|
export function isManzu(tile: TileType): tile is typeof MANZU_TILES[number] {
|
||||||
return PINZU_TILES.includes(tile);
|
return includes(MANZU_TILES, tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSouzu<T extends TileType>(tile: T): tile is typeof SOUZU_TILES[number] {
|
export function isPinzu(tile: TileType): tile is typeof PINZU_TILES[number] {
|
||||||
return SOUZU_TILES.includes(tile);
|
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 {
|
export function isSameNumberTile(a: TileType, b: TileType): boolean {
|
||||||
|
@ -328,16 +341,24 @@ export function fanToPoint(fan: number, isParent: boolean): number {
|
||||||
return point;
|
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 {
|
export function calcOwnedDoraCount(handTiles: TileType[], huros: Huro[], doras: TileType[]): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const t of handTiles) {
|
for (const t of handTiles) {
|
||||||
if (doras.includes(t)) count++;
|
if (doras.includes(t)) count++;
|
||||||
}
|
}
|
||||||
for (const huro of huros) {
|
for (const huro of huros) {
|
||||||
if (huro.type === 'pon' && doras.includes(huro.tile)) count += 3;
|
if (huro.type === 'pon' && includes(doras, huro.tiles[0])) count += 3;
|
||||||
if (huro.type === 'cii') count += huro.tiles.filter(t => doras.includes(t)).length;
|
if (huro.type === 'cii') count += huro.tiles.filter(t => includes(doras, t)).length;
|
||||||
if (huro.type === 'minkan' && doras.includes(huro.tile)) count += 4;
|
if (huro.type === 'minkan' && includes(doras, huro.tiles[0])) count += 4;
|
||||||
if (huro.type === 'ankan' && doras.includes(huro.tile)) count += 4;
|
if (huro.type === 'ankan' && includes(doras, huro.tiles[0])) count += 4;
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
@ -355,7 +376,7 @@ export function calcRedDoraCount(handTiles: TileId[], huros: Huro[]): number {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcTsumoHoraPointDeltas(house: House, fans: number): Record<House, number> {
|
export function calcTsumoHoraPointDeltas(house: House, fansOrFactor: number | PointFactor): Record<House, number> {
|
||||||
const isParent = house === 'e';
|
const isParent = house === 'e';
|
||||||
|
|
||||||
const deltas: Record<House, number> = {
|
const deltas: Record<House, number> = {
|
||||||
|
@ -365,7 +386,7 @@ export function calcTsumoHoraPointDeltas(house: House, fans: number): Record<Hou
|
||||||
n: 0,
|
n: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const point = fanToPoint(fans, isParent);
|
const point = typeof fansOrFactor == 'number' ? fanToPoint(fansOrFactor, isParent) : calcPoint(fansOrFactor, isParent);
|
||||||
deltas[house] = point;
|
deltas[house] = point;
|
||||||
if (isParent) {
|
if (isParent) {
|
||||||
const childPoint = Math.ceil(point / 3);
|
const childPoint = Math.ceil(point / 3);
|
||||||
|
@ -442,6 +463,10 @@ export function isKotsu(tiles: [TileType, TileType, TileType]): boolean {
|
||||||
return tiles[0] === tiles[1];
|
return tiles[0] === tiles[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mentsuEquals(tiles1: [TileType, TileType, TileType], tiles2: [TileType, TileType, TileType]): boolean {
|
||||||
|
return tiles1[0] == tiles2[0] && tiles1[1] == tiles2[1] && tiles1[2] == tiles2[2];
|
||||||
|
}
|
||||||
|
|
||||||
export const SHUNTU_PATTERNS: [TileType, TileType, TileType][] = [
|
export const SHUNTU_PATTERNS: [TileType, TileType, TileType][] = [
|
||||||
['m1', 'm2', 'm3'],
|
['m1', 'm2', 'm3'],
|
||||||
['m2', 'm3', 'm4'],
|
['m2', 'm3', 'm4'],
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CALL_HURO_TYPES, CHAR_TILES, FourMentsuOneJyantou, House, MANZU_TILES, PINZU_TILES, SOUZU_TILES, TileType, YAOCHU_TILES, TILE_TYPES, analyzeFourMentsuOneJyantou, isShuntu, isManzu, isPinzu, isSameNumberTile, isSouzu, isKotsu } from './common.js';
|
import { CALL_HURO_TYPES, CHAR_TILES, FourMentsuOneJyantou, House, MANZU_TILES, PINZU_TILES, SOUZU_TILES, TileType, YAOCHU_TILES, TILE_TYPES, analyzeFourMentsuOneJyantou, isShuntu, isManzu, isPinzu, isSameNumberTile, isSouzu, isKotsu, includes, TERMINAL_TILES, mentsuEquals, Huro, TILE_ID_MAP } from './common.js';
|
||||||
|
import { calcWaitPatterns, isRyanmen, isToitsu, FourMentsuOneJyantouWithWait } from './common.fu.js';
|
||||||
|
|
||||||
const RYUISO_TILES: TileType[] = ['s2', 's3', 's4', 's6', 's8', 'hatsu'];
|
const RYUISO_TILES: TileType[] = ['s2', 's3', 's4', 's6', 's8', 'hatsu'];
|
||||||
const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'];
|
const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'];
|
||||||
|
@ -67,11 +68,27 @@ export const YAKUMAN_NAMES = [
|
||||||
'chiho',
|
'chiho',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type YakuName = typeof NORMAL_YAKU_NAMES[number] | typeof YAKUMAN_NAMES[number];
|
type NormalYakuName = typeof NORMAL_YAKU_NAMES[number]
|
||||||
|
|
||||||
|
type YakumanName = typeof YAKUMAN_NAMES[number];
|
||||||
|
|
||||||
|
export type YakuName = NormalYakuName | YakumanName;
|
||||||
|
|
||||||
|
export type HuroForCalcYaku = {
|
||||||
|
type: 'pon';
|
||||||
|
tile: TileType;
|
||||||
|
} | {
|
||||||
|
type: 'cii';
|
||||||
|
tiles: [TileType, TileType, TileType];
|
||||||
|
} | {
|
||||||
|
type: 'ankan';
|
||||||
|
tile: TileType;
|
||||||
|
} | {
|
||||||
|
type: 'minkan';
|
||||||
|
tile: TileType;
|
||||||
|
};
|
||||||
|
|
||||||
export type EnvForCalcYaku = {
|
export type EnvForCalcYaku = {
|
||||||
house: House;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 和了る人の手牌(副露牌は含まず、ツモ、ロン牌は含む)
|
* 和了る人の手牌(副露牌は含まず、ツモ、ロン牌は含む)
|
||||||
*/
|
*/
|
||||||
|
@ -80,72 +97,221 @@ export type EnvForCalcYaku = {
|
||||||
/**
|
/**
|
||||||
* 河
|
* 河
|
||||||
*/
|
*/
|
||||||
hoTiles: TileType[];
|
hoTiles?: TileType[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 副露
|
* 副露
|
||||||
*/
|
*/
|
||||||
huros: ({
|
huros: HuroForCalcYaku[];
|
||||||
type: 'pon';
|
|
||||||
tile: TileType;
|
|
||||||
} | {
|
|
||||||
type: 'cii';
|
|
||||||
tiles: [TileType, TileType, TileType];
|
|
||||||
} | {
|
|
||||||
type: 'ankan';
|
|
||||||
tile: TileType;
|
|
||||||
} | {
|
|
||||||
type: 'minkan';
|
|
||||||
tile: TileType;
|
|
||||||
})[];
|
|
||||||
|
|
||||||
tsumoTile: TileType;
|
|
||||||
ronTile: TileType;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 場風
|
* 場風
|
||||||
*/
|
*/
|
||||||
fieldWind: House;
|
fieldWind?: House;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自風
|
* 自風
|
||||||
*/
|
*/
|
||||||
seatWind: House;
|
seatWind?: House;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 局が始まってから誰の副露もない一巡目かどうか
|
||||||
|
*/
|
||||||
|
firstTurn?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* リーチしたかどうか
|
* リーチしたかどうか
|
||||||
*/
|
*/
|
||||||
riichi: boolean;
|
riichi?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一巡目以内かどうか
|
* 誰の副露もない一巡目でリーチしたかどうか
|
||||||
*/
|
*/
|
||||||
ippatsu: boolean;
|
doubleRiichi?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* リーチしてから誰の副露もない一巡目以内かどうか
|
||||||
|
*/
|
||||||
|
ippatsu?: boolean;
|
||||||
|
} & ({
|
||||||
|
tsumoTile: TileType;
|
||||||
|
ronTile?: null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 嶺上牌のツモか
|
||||||
|
*/
|
||||||
|
rinshan?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 海底牌か
|
||||||
|
*/
|
||||||
|
haitei?: boolean;
|
||||||
|
} | {
|
||||||
|
tsumoTile?: null;
|
||||||
|
ronTile: TileType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 河底牌か
|
||||||
|
*/
|
||||||
|
hotei?: boolean;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface YakuDataBase {
|
||||||
|
name: YakuName;
|
||||||
|
upper?: YakuName | null;
|
||||||
|
fan?: number | null;
|
||||||
|
isYakuman?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalYakuData extends YakuDataBase {
|
||||||
|
name: NormalYakuName;
|
||||||
|
fan: number;
|
||||||
|
isYakuman?: false;
|
||||||
|
kuisagari?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YakumanData extends YakuDataBase {
|
||||||
|
name: YakumanName;
|
||||||
|
isYakuman: true;
|
||||||
|
isDoubleYakuman?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type YakuData = Required<NormalYakuData> | Required<YakumanData>;
|
||||||
|
|
||||||
|
abstract class YakuSetBase<IsYakuman extends boolean> {
|
||||||
|
public readonly isYakuman: IsYakuman;
|
||||||
|
|
||||||
|
public readonly yakus: YakuData[];
|
||||||
|
|
||||||
|
public get yakuNames(): YakuName[] {
|
||||||
|
return this.yakus.map(yaku => yaku.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(isYakuman: IsYakuman, yakus: YakuData[]) {
|
||||||
|
this.isYakuman = isYakuman;
|
||||||
|
this.yakus = yakus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NormalYakuSet extends YakuSetBase<false> {
|
||||||
|
public readonly isMenzen: boolean;
|
||||||
|
|
||||||
|
public readonly fan: number;
|
||||||
|
|
||||||
|
constructor(isMenzen: boolean, yakus: Required<NormalYakuData>[]) {
|
||||||
|
super(false, yakus);
|
||||||
|
this.isMenzen = isMenzen;
|
||||||
|
this.fan = yakus.reduce((fan, yaku) => fan + (!isMenzen && yaku.kuisagari ? yaku.fan - 1 : yaku.fan), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class YakumanSet extends YakuSetBase<true> {
|
||||||
|
/**
|
||||||
|
* 何倍役満か
|
||||||
|
*/
|
||||||
|
public readonly value: number;
|
||||||
|
|
||||||
|
constructor(yakus: Required<YakumanData>[]) {
|
||||||
|
super(true, yakus);
|
||||||
|
this.value = yakus.reduce((value, yaku) => value + (yaku.isDoubleYakuman ? 2 : 1), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type YakuSet = NormalYakuSet | YakumanSet;
|
||||||
|
|
||||||
|
type YakuDefinitionBase = {
|
||||||
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type YakuDefiniyion = {
|
type NormalYakuDefinition = YakuDefinitionBase & NormalYakuData;
|
||||||
name: YakuName;
|
|
||||||
upper?: YakuName;
|
type YakumanDefinition = YakuDefinitionBase & YakumanData;
|
||||||
fan?: number;
|
|
||||||
isYakuman?: boolean;
|
|
||||||
isDoubleYakuman?: boolean;
|
|
||||||
kuisagari?: boolean;
|
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function countTiles(tiles: TileType[], target: TileType): number {
|
function countTiles(tiles: TileType[], target: TileType): number {
|
||||||
return tiles.filter(t => t === target).length;
|
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',
|
name: 'tsumo',
|
||||||
fan: 1,
|
fan: 1,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
// 門前じゃないとダメ
|
||||||
|
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
|
||||||
// 面前じゃないとダメ
|
|
||||||
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
|
|
||||||
|
|
||||||
return state.tsumoTile != null;
|
return state.tsumoTile != null;
|
||||||
},
|
},
|
||||||
|
@ -154,173 +320,67 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
fan: 1,
|
fan: 1,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
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',
|
name: 'ippatsu',
|
||||||
fan: 1,
|
fan: 1,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
return state.ippatsu;
|
return state.ippatsu ?? false;
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
name: 'red',
|
name: 'rinshan',
|
||||||
fan: 1,
|
fan: 1,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
return (state.tsumoTile != null && state.rinshan) ?? 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)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}, {
|
}, {
|
||||||
name: 'white',
|
name: 'haitei',
|
||||||
fan: 1,
|
fan: 1,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
return (state.tsumoTile != null && state.haitei) ?? 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)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}, {
|
}, {
|
||||||
name: 'green',
|
name: 'hotei',
|
||||||
fan: 1,
|
fan: 1,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
return (state.ronTile != null && state.hotei) ?? false;
|
||||||
|
}
|
||||||
return (
|
},
|
||||||
(countTiles(state.handTiles, 'hatsu') >= 3) ||
|
new Yakuhai('red', 'chun'),
|
||||||
(state.huros.filter(huro =>
|
new Yakuhai('white', 'haku'),
|
||||||
huro.type === 'pon' ? huro.tile === 'hatsu' :
|
new Yakuhai('green', 'hatsu'),
|
||||||
huro.type === 'ankan' ? huro.tile === 'hatsu' :
|
new FieldWind('field-wind-e', 'e'),
|
||||||
huro.type === 'minkan' ? huro.tile === 'hatsu' :
|
new FieldWind('field-wind-s', 's'),
|
||||||
false).length >= 3)
|
new FieldWind('field-wind-w', 'w'),
|
||||||
);
|
new FieldWind('field-wind-n', 'n'),
|
||||||
},
|
new SeatWind('seat-wind-e', 'e'),
|
||||||
}, {
|
new SeatWind('seat-wind-s', 's'),
|
||||||
name: 'field-wind-e',
|
new SeatWind('seat-wind-w', 'w'),
|
||||||
fan: 1,
|
new SeatWind('seat-wind-n', 'n'),
|
||||||
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)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: 'tanyao',
|
name: 'tanyao',
|
||||||
fan: 1,
|
fan: 1,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(!state.handTiles.some(t => YAOCHU_TILES.includes(t))) &&
|
(!state.handTiles.some(t => includes(YAOCHU_TILES, t))) &&
|
||||||
(state.huros.filter(huro =>
|
(state.huros.filter(huro =>
|
||||||
huro.type === 'pon' ? YAOCHU_TILES.includes(huro.tile) :
|
huro.type === 'pon' ? includes(YAOCHU_TILES, huro.tile) :
|
||||||
huro.type === 'ankan' ? YAOCHU_TILES.includes(huro.tile) :
|
huro.type === 'ankan' ? includes(YAOCHU_TILES, huro.tile) :
|
||||||
huro.type === 'minkan' ? YAOCHU_TILES.includes(huro.tile) :
|
huro.type === 'minkan' ? includes(YAOCHU_TILES, huro.tile) :
|
||||||
huro.type === 'cii' ? huro.tiles.some(t2 => YAOCHU_TILES.includes(t2)) :
|
huro.type === 'cii' ? huro.tiles.some(t2 => includes(YAOCHU_TILES, t2)) :
|
||||||
false).length === 0)
|
false).length === 0)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -328,15 +388,16 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
name: 'pinfu',
|
name: 'pinfu',
|
||||||
fan: 1,
|
fan: 1,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
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;
|
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)
|
// 風牌判定(役牌でなければOK)
|
||||||
if (fourMentsuOneJyantou.head === state.seatWind) return false;
|
if (fourMentsuOneJyantou.head === state.seatWind) return false;
|
||||||
|
@ -353,20 +414,18 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
kuisagari: true,
|
kuisagari: true,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
|
||||||
|
|
||||||
const tiles = state.handTiles;
|
const tiles = state.handTiles;
|
||||||
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
|
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||||
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
|
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||||
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
|
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||||
let charCount = tiles.filter(t => CHAR_TILES.includes(t)).length;
|
let charCount = tiles.filter(t => includes(CHAR_TILES, t)).length;
|
||||||
|
|
||||||
for (const huro of state.huros) {
|
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];
|
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;
|
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||||
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
|
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||||
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
|
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||||
charCount += huroTiles.filter(t => CHAR_TILES.includes(t)).length;
|
charCount += huroTiles.filter(t => includes(CHAR_TILES, t)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manzuCount > 0 && pinzuCount > 0) return false;
|
if (manzuCount > 0 && pinzuCount > 0) return false;
|
||||||
|
@ -382,20 +441,18 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
kuisagari: true,
|
kuisagari: true,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
|
||||||
|
|
||||||
const tiles = state.handTiles;
|
const tiles = state.handTiles;
|
||||||
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
|
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||||
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
|
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||||
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
|
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||||
let charCount = tiles.filter(t => CHAR_TILES.includes(t)).length;
|
let charCount = tiles.filter(t => includes(CHAR_TILES, t)).length;
|
||||||
|
|
||||||
for (const huro of state.huros) {
|
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];
|
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;
|
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||||
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
|
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||||
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
|
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||||
charCount += huroTiles.filter(t => CHAR_TILES.includes(t)).length;
|
charCount += huroTiles.filter(t => includes(CHAR_TILES, t)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (charCount > 0) return false;
|
if (charCount > 0) return false;
|
||||||
|
@ -413,12 +470,23 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
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つあるか?
|
// 同じ順子が2つあるか?
|
||||||
return fourMentsuOneJyantou.mentsus.some((mentsu) =>
|
return countIndenticalMentsuPairs(fourMentsuOneJyantou.mentsus) == 1;
|
||||||
fourMentsuOneJyantou.mentsus.filter((mentsu2) =>
|
},
|
||||||
mentsu2[0] === mentsu[0] && mentsu2[1] === mentsu[1] && mentsu2[2] === mentsu[2]).length >= 2);
|
}, {
|
||||||
|
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',
|
name: 'toitoi',
|
||||||
|
@ -440,9 +508,25 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
name: 'sananko',
|
name: 'sananko',
|
||||||
fan: 2,
|
fan: 2,
|
||||||
isYakuman: false,
|
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',
|
name: 'sanshoku-dojun',
|
||||||
fan: 2,
|
fan: 2,
|
||||||
|
@ -520,6 +604,7 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
if (fourMentsuOneJyantou == null) return false;
|
||||||
|
|
||||||
const shuntsus = fourMentsuOneJyantou.mentsus.filter(tiles => isShuntu(tiles));
|
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] === 'm1' && tiles[1] === 'm2' && tiles[2] === 'm3')) {
|
||||||
if (shuntsus.some(tiles => tiles[0] === 'm4' && tiles[1] === 'm5' && tiles[2] === 'm6')) {
|
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;
|
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',
|
name: 'chitoitsu',
|
||||||
fan: 2,
|
fan: 2,
|
||||||
isYakuman: false,
|
isYakuman: false,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
|
if (fourMentsuOneJyantou != null) return false;
|
||||||
if (state.huros.length > 0) return false;
|
if (state.huros.length > 0) return false;
|
||||||
const countMap = new Map<TileType, number>();
|
const countMap = new Map<TileType, number>();
|
||||||
for (const tile of state.handTiles) {
|
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',
|
name: 'daisangen',
|
||||||
isYakuman: true,
|
isYakuman: true,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
|
@ -656,19 +807,17 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
}, {
|
}, {
|
||||||
name: 'tsuiso',
|
name: 'tsuiso',
|
||||||
isYakuman: true,
|
isYakuman: true,
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku) => {
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
|
||||||
|
|
||||||
const tiles = state.handTiles;
|
const tiles = state.handTiles;
|
||||||
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
|
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||||
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
|
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||||
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
|
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||||
|
|
||||||
for (const huro of state.huros) {
|
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];
|
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;
|
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||||
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
|
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||||
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
|
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manzuCount > 0 || pinzuCount > 0 || souzuCount > 0) return false;
|
if (manzuCount > 0 || pinzuCount > 0 || souzuCount > 0) return false;
|
||||||
|
@ -690,6 +839,21 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
|
|
||||||
return true;
|
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',
|
name: 'churen-9',
|
||||||
isYakuman: true,
|
isYakuman: true,
|
||||||
|
@ -698,9 +862,12 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
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;
|
const agariTile = state.tsumoTile ?? state.ronTile;
|
||||||
|
if (agariTile == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const tempaiTiles = [...state.handTiles];
|
const tempaiTiles = [...state.handTiles];
|
||||||
tempaiTiles.splice(state.handTiles.indexOf(agariTile), 1);
|
tempaiTiles.splice(state.handTiles.indexOf(agariTile), 1);
|
||||||
|
|
||||||
|
@ -734,7 +901,7 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
if (fourMentsuOneJyantou == null) return false;
|
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 (isManzu(state.handTiles[0])) {
|
||||||
if ((countTiles(state.handTiles, 'm1') === 3) && (countTiles(state.handTiles, 'm9') === 3)) {
|
if ((countTiles(state.handTiles, 'm1') === 3) && (countTiles(state.handTiles, 'm9') === 3)) {
|
||||||
|
@ -758,44 +925,120 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
||||||
|
|
||||||
return false;
|
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',
|
name: 'kokushi',
|
||||||
isYakuman: true,
|
isYakuman: true,
|
||||||
|
upper: 'kokushi-13',
|
||||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||||
return KOKUSHI_TILES.every(t => state.handTiles.includes(t));
|
return KOKUSHI_TILES.every(t => state.handTiles.includes(t)) && KOKUSHI_TILES.some(t => countTiles(state.handTiles, t) == 2);
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
name: 'tenho',
|
||||||
|
isYakuman: true,
|
||||||
|
calc: (state: EnvForCalcYaku) => {
|
||||||
|
return (state.firstTurn ?? false) && state.tsumoTile != null && state.seatWind == 'e';
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
name: 'chiho',
|
||||||
|
isYakuman: true,
|
||||||
|
calc: (state: EnvForCalcYaku) => {
|
||||||
|
return (state.firstTurn ?? false) && state.tsumoTile != null && state.seatWind != 'e';
|
||||||
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
export const YAKU_DEFINITIONS = NORMAL_YAKU_DEFINITIONS.concat(YAKUMAN_DEFINITIONS);
|
export function convertHuroForCalcYaku(huro: Huro): HuroForCalcYaku {
|
||||||
|
switch (huro.type) {
|
||||||
|
case 'pon':
|
||||||
|
case 'ankan':
|
||||||
|
case 'minkan':
|
||||||
|
return {
|
||||||
|
type: huro.type,
|
||||||
|
tile: TILE_ID_MAP.get(huro.tiles[0])!.t,
|
||||||
|
}
|
||||||
|
case 'cii':
|
||||||
|
return {
|
||||||
|
type: 'cii',
|
||||||
|
tiles: huro.tiles.map(tile => TILE_ID_MAP.get(tile)!.t) as [TileType, TileType, TileType],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NORMAL_YAKU_DATA_MAP = new Map<NormalYakuName, Required<NormalYakuData>>(
|
||||||
|
NORMAL_YAKU_DEFINITIONS.map(yaku => [yaku.name, {
|
||||||
|
name: yaku.name,
|
||||||
|
upper: yaku.upper ?? null,
|
||||||
|
fan: yaku.fan,
|
||||||
|
isYakuman: false,
|
||||||
|
kuisagari: yaku.kuisagari ?? false,
|
||||||
|
}] as const)
|
||||||
|
);
|
||||||
|
|
||||||
|
const YAKUMAN_DATA_MAP = new Map<YakuName, Required<YakumanData>>(
|
||||||
|
YAKUMAN_DEFINITIONS.map(yaku => [yaku.name, {
|
||||||
|
name: yaku.name,
|
||||||
|
upper: yaku.upper ?? null,
|
||||||
|
fan: null,
|
||||||
|
isYakuman: true,
|
||||||
|
isDoubleYakuman: yaku.isDoubleYakuman ?? false,
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
|
||||||
|
export function calcYakusWithDetail(state: EnvForCalcYaku): YakuSet {
|
||||||
|
if (state.riichi && state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type)) ) {
|
||||||
|
throw new TypeError('Invalid riichi state with call huros');
|
||||||
|
}
|
||||||
|
|
||||||
|
const agariTile = state.tsumoTile ?? state.ronTile;
|
||||||
|
if (!state.handTiles.includes(agariTile)) {
|
||||||
|
throw new TypeError('Agari tile not included in hand tiles');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.handTiles.length + state.huros.length * 3 != 14) {
|
||||||
|
throw new TypeError('Invalid tile count');
|
||||||
|
}
|
||||||
|
|
||||||
export function calcYakus(state: EnvForCalcYaku): YakuName[] {
|
|
||||||
const oneHeadFourMentsuPatterns: (FourMentsuOneJyantou | null)[] = analyzeFourMentsuOneJyantou(state.handTiles);
|
const oneHeadFourMentsuPatterns: (FourMentsuOneJyantou | null)[] = analyzeFourMentsuOneJyantou(state.handTiles);
|
||||||
if (oneHeadFourMentsuPatterns.length === 0) oneHeadFourMentsuPatterns.push(null);
|
if (oneHeadFourMentsuPatterns.length === 0) oneHeadFourMentsuPatterns.push(null);
|
||||||
|
|
||||||
const yakumanPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => {
|
const waitPatterns = oneHeadFourMentsuPatterns.map(
|
||||||
const matchedYakus: YakuDefiniyion[] = [];
|
fourMentsuOneJyantou => calcWaitPatterns(fourMentsuOneJyantou, agariTile)
|
||||||
for (const yakuDef of YAKUMAN_DEFINITIONS) {
|
).flat();
|
||||||
if (yakuDef.upper && matchedYakus.some(yaku => yaku.name === yakuDef.upper)) continue;
|
|
||||||
const matched = yakuDef.calc(state, fourMentsuOneJyantou);
|
const yakumanPatterns = waitPatterns.map(fourMentsuOneJyantouWithWait => {
|
||||||
if (matched) {
|
const matchedYakus: Required<YakumanData>[] = [];
|
||||||
matchedYakus.push(yakuDef);
|
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;
|
||||||
return matchedYakus;
|
}).filter(yakus => yakus.length > 0);
|
||||||
}).filter(yakus => yakus.length > 0);
|
|
||||||
|
|
||||||
if (yakumanPatterns.length > 0) {
|
if (yakumanPatterns.length > 0) {
|
||||||
return yakumanPatterns[0].map(yaku => yaku.name);
|
return new YakumanSet(yakumanPatterns[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const yakuPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => {
|
const yakuPatterns = waitPatterns.map(
|
||||||
return NORMAL_YAKU_DEFINITIONS.map(yakuDef => {
|
fourMentsuOneJyantouWithWait => NORMAL_YAKU_DEFINITIONS.filter(
|
||||||
const result = yakuDef.calc(state, fourMentsuOneJyantou);
|
yakuDef => yakuDef.calc(state, fourMentsuOneJyantouWithWait)
|
||||||
return result ? yakuDef : null;
|
).map(yakuDef => NORMAL_YAKU_DATA_MAP.get(yakuDef.name)!)
|
||||||
}).filter(yaku => yaku != null) as YakuDefiniyion[];
|
).filter(yakus => yakus.length > 0);
|
||||||
}).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 maxYakus = yakuPatterns[0];
|
||||||
let maxFan = 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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,9 @@ import CRC32 from 'crc-32';
|
||||||
import { TileType, House, Huro, TileId } from './common.js';
|
import { TileType, House, Huro, TileId } from './common.js';
|
||||||
import * as Common from './common.js';
|
import * as Common from './common.js';
|
||||||
import { PlayerState } from './engine.player.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
|
//#region syntax suger
|
||||||
function $(tid: TileId): Common.TileInstance {
|
function $(tid: TileId): Common.TileInstance {
|
||||||
|
@ -134,13 +136,33 @@ class StateManager {
|
||||||
pattern.filter(t => hand.includes(t)).length >= 2);
|
pattern.filter(t => hand.includes(t)).length >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public tsumo(): TileId {
|
private withTsumoTile(tile: TileId | undefined, isRinshan: boolean): TileId {
|
||||||
const tile = this.$state.tiles.pop();
|
|
||||||
if (tile == null) throw new Error('No tiles left');
|
if (tile == null) throw new Error('No tiles left');
|
||||||
if (this.$state.turn == null) throw new Error('Not your turn');
|
if (this.$state.turn == null) throw new Error('Not your turn');
|
||||||
this.$state.handTiles[this.$state.turn].push(tile);
|
this.$state.handTiles[this.$state.turn].push(tile);
|
||||||
|
this.$state.rinshanFlags[this.$state.turn] = isRinshan;
|
||||||
return tile;
|
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 = {
|
export type MasterState = {
|
||||||
|
@ -178,18 +200,36 @@ export type MasterState = {
|
||||||
w: Huro[];
|
w: Huro[];
|
||||||
n: Huro[];
|
n: Huro[];
|
||||||
};
|
};
|
||||||
|
firstTurnFlags: {
|
||||||
|
e: boolean;
|
||||||
|
s: boolean;
|
||||||
|
w: boolean;
|
||||||
|
n: boolean;
|
||||||
|
};
|
||||||
riichis: {
|
riichis: {
|
||||||
e: boolean;
|
e: boolean;
|
||||||
s: boolean;
|
s: boolean;
|
||||||
w: boolean;
|
w: boolean;
|
||||||
n: boolean;
|
n: boolean;
|
||||||
};
|
};
|
||||||
|
doubleRiichis: {
|
||||||
|
e: boolean;
|
||||||
|
s: boolean;
|
||||||
|
w: boolean;
|
||||||
|
n: boolean;
|
||||||
|
};
|
||||||
ippatsus: {
|
ippatsus: {
|
||||||
e: boolean;
|
e: boolean;
|
||||||
s: boolean;
|
s: boolean;
|
||||||
w: boolean;
|
w: boolean;
|
||||||
n: boolean;
|
n: boolean;
|
||||||
};
|
};
|
||||||
|
rinshanFlags: {
|
||||||
|
e: boolean;
|
||||||
|
s: boolean;
|
||||||
|
w: boolean;
|
||||||
|
n: boolean;
|
||||||
|
}
|
||||||
points: {
|
points: {
|
||||||
e: number;
|
e: number;
|
||||||
s: number;
|
s: number;
|
||||||
|
@ -304,7 +344,7 @@ export class MasterGameEngine {
|
||||||
return this.stateManager.turn;
|
return this.stateManager.turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static createInitialState(): MasterState {
|
public static createInitialState(preset: Partial<MasterState> = {}): MasterState {
|
||||||
const ikasama: TileId[] = [125, 129, 9, 56, 57, 61, 77, 81, 85, 133, 134, 135, 121, 122];
|
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()]);
|
const tiles = shuffle([...Common.TILE_ID_MAP.keys()]);
|
||||||
|
@ -350,23 +390,41 @@ export class MasterGameEngine {
|
||||||
w: [],
|
w: [],
|
||||||
n: [],
|
n: [],
|
||||||
},
|
},
|
||||||
|
firstTurnFlags: {
|
||||||
|
e: true,
|
||||||
|
s: true,
|
||||||
|
w: true,
|
||||||
|
n: true,
|
||||||
|
},
|
||||||
riichis: {
|
riichis: {
|
||||||
e: false,
|
e: false,
|
||||||
s: false,
|
s: false,
|
||||||
w: false,
|
w: false,
|
||||||
n: false,
|
n: false,
|
||||||
},
|
},
|
||||||
|
doubleRiichis: {
|
||||||
|
e: false,
|
||||||
|
s: false,
|
||||||
|
w: false,
|
||||||
|
n: false,
|
||||||
|
},
|
||||||
ippatsus: {
|
ippatsus: {
|
||||||
e: false,
|
e: false,
|
||||||
s: false,
|
s: false,
|
||||||
w: false,
|
w: false,
|
||||||
n: false,
|
n: false,
|
||||||
},
|
},
|
||||||
|
rinshanFlags: {
|
||||||
|
e: false,
|
||||||
|
s: false,
|
||||||
|
w: false,
|
||||||
|
n: false,
|
||||||
|
},
|
||||||
points: {
|
points: {
|
||||||
e: 25000,
|
e: INITIAL_POINT,
|
||||||
s: 25000,
|
s: INITIAL_POINT,
|
||||||
w: 25000,
|
w: INITIAL_POINT,
|
||||||
n: 25000,
|
n: INITIAL_POINT,
|
||||||
},
|
},
|
||||||
turn: 'e',
|
turn: 'e',
|
||||||
nextTurnAfterAsking: null,
|
nextTurnAfterAsking: null,
|
||||||
|
@ -376,6 +434,7 @@ export class MasterGameEngine {
|
||||||
cii: null,
|
cii: null,
|
||||||
kan: null,
|
kan: null,
|
||||||
},
|
},
|
||||||
|
...preset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -433,8 +492,14 @@ export class MasterGameEngine {
|
||||||
if (riichi) {
|
if (riichi) {
|
||||||
tx.$state.riichis[house] = true;
|
tx.$state.riichis[house] = true;
|
||||||
tx.$state.ippatsus[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[] = [];
|
const canRonHouses: House[] = [];
|
||||||
switch (house) {
|
switch (house) {
|
||||||
case 'e':
|
case 'e':
|
||||||
|
@ -548,20 +613,17 @@ export class MasterGameEngine {
|
||||||
public commit_kakan(house: House, tid: TileId) {
|
public commit_kakan(house: House, tid: TileId) {
|
||||||
const tx = this.startTransaction();
|
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');
|
if (pon == null) throw new Error('No such pon');
|
||||||
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(tid), 1);
|
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.huros[house].push({ type: 'minkan', tiles: tiles, from: pon.from });
|
||||||
|
|
||||||
tx.$state.ippatsus.e = false;
|
tx.clearFirstTurnAndIppatsus();
|
||||||
tx.$state.ippatsus.s = false;
|
|
||||||
tx.$state.ippatsus.w = false;
|
|
||||||
tx.$state.ippatsus.n = false;
|
|
||||||
|
|
||||||
tx.$state.activatedDorasCount++;
|
tx.$state.activatedDorasCount++;
|
||||||
|
|
||||||
const rinsyan = tx.tsumo();
|
const rinsyan = tx.rinshanTsumo();
|
||||||
|
|
||||||
tx.$commit();
|
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(t2), 1);
|
||||||
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t3), 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);
|
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.huros[house].push({ type: 'ankan', tiles: tiles });
|
||||||
|
|
||||||
tx.$state.ippatsus.e = false;
|
tx.clearFirstTurnAndIppatsus();
|
||||||
tx.$state.ippatsus.s = false;
|
|
||||||
tx.$state.ippatsus.w = false;
|
|
||||||
tx.$state.ippatsus.n = false;
|
|
||||||
|
|
||||||
tx.$state.activatedDorasCount++;
|
tx.$state.activatedDorasCount++;
|
||||||
|
|
||||||
const rinsyan = tx.tsumo();
|
const rinsyan = tx.rinshanTsumo();
|
||||||
|
|
||||||
tx.$commit();
|
tx.$commit();
|
||||||
|
|
||||||
|
@ -611,36 +670,40 @@ export class MasterGameEngine {
|
||||||
* ツモ和了
|
* ツモ和了
|
||||||
* @param house
|
* @param house
|
||||||
*/
|
*/
|
||||||
public commit_tsumoHora(house: House) {
|
public commit_tsumoHora(house: House, doLog = true) {
|
||||||
const tx = this.startTransaction();
|
const tx = this.startTransaction();
|
||||||
|
|
||||||
if (tx.$state.turn !== house) throw new Error('Not your turn');
|
if (tx.$state.turn !== house) throw new Error('Not your turn');
|
||||||
|
|
||||||
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
|
const yakus = calcYakusWithDetail({
|
||||||
house: house,
|
seatWind: house,
|
||||||
handTiles: tx.handTileTypes[house],
|
handTiles: tx.handTileTypes[house],
|
||||||
huros: tx.$state.huros[house],
|
huros: tx.$state.huros[house].map(convertHuroForCalcYaku),
|
||||||
tsumoTile: tx.handTileTypes[house].at(-1)!,
|
tsumoTile: tx.handTileTypes[house].at(-1)!,
|
||||||
ronTile: null,
|
ronTile: null,
|
||||||
|
firstTurn: tx.$state.firstTurnFlags[house],
|
||||||
riichi: tx.$state.riichis[house],
|
riichi: tx.$state.riichis[house],
|
||||||
|
doubleRiichi: tx.$state.doubleRiichis[house],
|
||||||
ippatsu: tx.$state.ippatsus[house],
|
ippatsu: tx.$state.ippatsus[house],
|
||||||
}));
|
rinshan: tx.$state.rinshanFlags[house],
|
||||||
|
haitei: tx.$state.tiles.length == 0,
|
||||||
|
});
|
||||||
const doraCount =
|
const doraCount =
|
||||||
Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) +
|
Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) +
|
||||||
Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]);
|
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, yakus);
|
||||||
const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans);
|
|
||||||
tx.$state.points.e += pointDeltas.e;
|
tx.$state.points.e += pointDeltas.e;
|
||||||
tx.$state.points.s += pointDeltas.s;
|
tx.$state.points.s += pointDeltas.s;
|
||||||
tx.$state.points.w += pointDeltas.w;
|
tx.$state.points.w += pointDeltas.w;
|
||||||
tx.$state.points.n += pointDeltas.n;
|
tx.$state.points.n += pointDeltas.n;
|
||||||
console.log('yakus', house, yakus);
|
if (doLog) console.log('yakus', house, yakus);
|
||||||
|
|
||||||
tx.$commit();
|
tx.$commit();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handTiles: tx.$state.handTiles[house],
|
handTiles: tx.$state.handTiles[house],
|
||||||
tsumoTile: tx.$state.handTiles[house].at(-1)!,
|
tsumoTile: tx.$state.handTiles[house].at(-1)!,
|
||||||
|
yakus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -649,7 +712,7 @@ export class MasterGameEngine {
|
||||||
cii: false | 'x__' | '_x_' | '__x';
|
cii: false | 'x__' | '_x_' | '__x';
|
||||||
kan: boolean;
|
kan: boolean;
|
||||||
ron: House[];
|
ron: House[];
|
||||||
}) {
|
}, doLog = true) {
|
||||||
const tx = this.startTransaction();
|
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();
|
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 callers = answers.ron;
|
||||||
const callee = ron.callee;
|
const callee = ron.callee;
|
||||||
|
|
||||||
for (const house of callers) {
|
const yakus: { [K in House]?: YakuSet } = Object.fromEntries(callers.map(house => {
|
||||||
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
|
const ronTile = tx.hoTileTypes[callee].at(-1)!;
|
||||||
house: house,
|
const yakus = calcYakusWithDetail({
|
||||||
handTiles: tx.handTileTypes[house],
|
seatWind: house,
|
||||||
huros: tx.$state.huros[house],
|
handTiles: tx.handTileTypes[house].concat([ronTile]),
|
||||||
|
huros: tx.$state.huros[house].map(convertHuroForCalcYaku),
|
||||||
tsumoTile: null,
|
tsumoTile: null,
|
||||||
ronTile: tx.hoTileTypes[callee].at(-1)!,
|
ronTile,
|
||||||
|
firstTurn: tx.$state.firstTurnFlags[house],
|
||||||
riichi: tx.$state.riichis[house],
|
riichi: tx.$state.riichis[house],
|
||||||
|
doubleRiichi: tx.$state.doubleRiichis[house],
|
||||||
ippatsu: tx.$state.ippatsus[house],
|
ippatsu: tx.$state.ippatsus[house],
|
||||||
}));
|
hotei: tx.$state.tiles.length == 0,
|
||||||
|
});
|
||||||
const doraCount =
|
const doraCount =
|
||||||
Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) +
|
Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) +
|
||||||
Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]);
|
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.calcPoint(yakus, house === 'e');
|
||||||
const point = Common.fanToPoint(fans, house === 'e');
|
|
||||||
tx.$state.points[callee] -= point;
|
tx.$state.points[callee] -= point;
|
||||||
tx.$state.points[house] += point;
|
tx.$state.points[house] += point;
|
||||||
console.log('fans point', fans, point);
|
if (doLog) {
|
||||||
console.log('yakus', house, yakus);
|
console.log('yakus', house, yakus);
|
||||||
}
|
}
|
||||||
|
return [house, yakus] as const;
|
||||||
|
}));
|
||||||
|
|
||||||
tx.$commit();
|
tx.$commit();
|
||||||
|
|
||||||
|
@ -696,6 +764,7 @@ export class MasterGameEngine {
|
||||||
callers: ron.callers,
|
callers: ron.callers,
|
||||||
callee: ron.callee,
|
callee: ron.callee,
|
||||||
turn: null,
|
turn: null,
|
||||||
|
yakus,
|
||||||
};
|
};
|
||||||
} else if (kan != null && answers.kan) {
|
} 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(t2), 1);
|
||||||
tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t3), 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.huros[kan.caller].push({ type: 'minkan', tiles: tiles, from: kan.callee });
|
||||||
|
|
||||||
tx.$state.ippatsus.e = false;
|
tx.clearFirstTurnAndIppatsus();
|
||||||
tx.$state.ippatsus.s = false;
|
|
||||||
tx.$state.ippatsus.w = false;
|
|
||||||
tx.$state.ippatsus.n = false;
|
|
||||||
|
|
||||||
tx.$state.activatedDorasCount++;
|
tx.$state.activatedDorasCount++;
|
||||||
|
|
||||||
const rinsyan = tx.tsumo();
|
const rinsyan = tx.rinshanTsumo();
|
||||||
|
|
||||||
tx.$state.turn = kan.caller;
|
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(t1), 1);
|
||||||
tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t2), 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.huros[pon.caller].push({ type: 'pon', tiles: tiles, from: pon.callee });
|
||||||
|
|
||||||
tx.$state.ippatsus.e = false;
|
tx.clearFirstTurnAndIppatsus();
|
||||||
tx.$state.ippatsus.s = false;
|
|
||||||
tx.$state.ippatsus.w = false;
|
|
||||||
tx.$state.ippatsus.n = false;
|
|
||||||
|
|
||||||
tx.$state.turn = pon.caller;
|
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.huros[cii.caller].push({ type: 'cii', tiles: tiles, from: cii.callee });
|
||||||
|
|
||||||
tx.$state.ippatsus.e = false;
|
tx.clearFirstTurnAndIppatsus();
|
||||||
tx.$state.ippatsus.s = false;
|
|
||||||
tx.$state.ippatsus.w = false;
|
|
||||||
tx.$state.ippatsus.n = false;
|
|
||||||
|
|
||||||
tx.$state.turn = cii.caller;
|
tx.$state.turn = cii.caller;
|
||||||
|
|
||||||
|
@ -891,18 +951,36 @@ export class MasterGameEngine {
|
||||||
w: this.$state.huros.w,
|
w: this.$state.huros.w,
|
||||||
n: this.$state.huros.n,
|
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: {
|
riichis: {
|
||||||
e: this.$state.riichis.e,
|
e: this.$state.riichis.e,
|
||||||
s: this.$state.riichis.s,
|
s: this.$state.riichis.s,
|
||||||
w: this.$state.riichis.w,
|
w: this.$state.riichis.w,
|
||||||
n: this.$state.riichis.n,
|
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: {
|
ippatsus: {
|
||||||
e: this.$state.ippatsus.e,
|
e: this.$state.ippatsus.e,
|
||||||
s: this.$state.ippatsus.s,
|
s: this.$state.ippatsus.s,
|
||||||
w: this.$state.ippatsus.w,
|
w: this.$state.ippatsus.w,
|
||||||
n: this.$state.ippatsus.n,
|
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: {
|
points: {
|
||||||
e: this.$state.points.e,
|
e: this.$state.points.e,
|
||||||
s: this.$state.points.s,
|
s: this.$state.points.s,
|
||||||
|
@ -911,6 +989,10 @@ export class MasterGameEngine {
|
||||||
},
|
},
|
||||||
latestDahaiedTile: null,
|
latestDahaiedTile: null,
|
||||||
turn: this.$state.turn,
|
turn: this.$state.turn,
|
||||||
|
canPon: null,
|
||||||
|
canCii: null,
|
||||||
|
canKan: null,
|
||||||
|
canRon: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import CRC32 from 'crc-32';
|
import CRC32 from 'crc-32';
|
||||||
import { TileType, House, Huro, TileId } from './common.js';
|
import { TileType, House, Huro, TileId } from './common.js';
|
||||||
import * as Common 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
|
//#region syntax suger
|
||||||
function $(tid: TileId): Common.TileInstance {
|
function $(tid: TileId): Common.TileInstance {
|
||||||
|
@ -53,18 +53,36 @@ export type PlayerState = {
|
||||||
w: Huro[];
|
w: Huro[];
|
||||||
n: Huro[];
|
n: Huro[];
|
||||||
};
|
};
|
||||||
|
firstTurnFlags: {
|
||||||
|
e: boolean;
|
||||||
|
s: boolean;
|
||||||
|
w: boolean;
|
||||||
|
n: boolean;
|
||||||
|
};
|
||||||
riichis: {
|
riichis: {
|
||||||
e: boolean;
|
e: boolean;
|
||||||
s: boolean;
|
s: boolean;
|
||||||
w: boolean;
|
w: boolean;
|
||||||
n: boolean;
|
n: boolean;
|
||||||
};
|
};
|
||||||
|
doubleRiichis: {
|
||||||
|
e: boolean;
|
||||||
|
s: boolean;
|
||||||
|
w: boolean;
|
||||||
|
n: boolean;
|
||||||
|
};
|
||||||
ippatsus: {
|
ippatsus: {
|
||||||
e: boolean;
|
e: boolean;
|
||||||
s: boolean;
|
s: boolean;
|
||||||
w: boolean;
|
w: boolean;
|
||||||
n: boolean;
|
n: boolean;
|
||||||
};
|
};
|
||||||
|
rinshanFlags: {
|
||||||
|
e: boolean;
|
||||||
|
s: boolean;
|
||||||
|
w: boolean;
|
||||||
|
n: boolean;
|
||||||
|
}
|
||||||
points: {
|
points: {
|
||||||
e: number;
|
e: number;
|
||||||
s: number;
|
s: number;
|
||||||
|
@ -80,7 +98,7 @@ export type PlayerState = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KyokuResult = {
|
export type KyokuResult = {
|
||||||
yakus: { name: string; fan: number; isYakuman: boolean; }[];
|
yakus: { name: string; fan: number | null; isYakuman: boolean; }[];
|
||||||
doraCount: number;
|
doraCount: number;
|
||||||
pointDeltas: { e: number; s: number; w: number; n: 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 {
|
public commit_tsumoHora(house: House, handTiles: TileId[], tsumoTile: TileId): KyokuResult {
|
||||||
console.log('commit_tsumoHora', this.state.turn, house);
|
console.log('commit_tsumoHora', this.state.turn, house);
|
||||||
|
|
||||||
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
|
const yakus = calcYakusWithDetail({
|
||||||
house: house,
|
seatWind: house,
|
||||||
handTiles: handTiles.map(id => $type(id)),
|
handTiles: handTiles.map(id => $type(id)),
|
||||||
huros: this.state.huros[house],
|
huros: this.state.huros[house].map(convertHuroForCalcYaku),
|
||||||
tsumoTile: $type(tsumoTile),
|
tsumoTile: $type(tsumoTile),
|
||||||
ronTile: null,
|
ronTile: null,
|
||||||
|
firstTurn: this.state.firstTurnFlags[house],
|
||||||
riichi: this.state.riichis[house],
|
riichi: this.state.riichis[house],
|
||||||
|
doubleRiichi: this.state.doubleRiichis[house],
|
||||||
ippatsu: this.state.ippatsus[house],
|
ippatsu: this.state.ippatsus[house],
|
||||||
}));
|
rinshan: this.state.rinshanFlags[house],
|
||||||
|
haitei: this.state.tilesCount == 0,
|
||||||
|
});
|
||||||
const doraCount =
|
const doraCount =
|
||||||
Common.calcOwnedDoraCount(handTiles.map(id => $type(id)), this.state.huros[house], this.doras) +
|
Common.calcOwnedDoraCount(handTiles.map(id => $type(id)), this.state.huros[house], this.doras) +
|
||||||
Common.calcRedDoraCount(handTiles, this.state.huros[house]);
|
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, yakus);
|
||||||
const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans);
|
|
||||||
this.state.points.e += pointDeltas.e;
|
this.state.points.e += pointDeltas.e;
|
||||||
this.state.points.s += pointDeltas.s;
|
this.state.points.s += pointDeltas.s;
|
||||||
this.state.points.w += pointDeltas.w;
|
this.state.points.w += pointDeltas.w;
|
||||||
this.state.points.n += pointDeltas.n;
|
this.state.points.n += pointDeltas.n;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
yakus: yakus.map(yaku => ({
|
yakus: yakus.yakus,
|
||||||
name: yaku.name,
|
|
||||||
fan: yaku.fan,
|
|
||||||
isYakuman: yaku.isYakuman,
|
|
||||||
})),
|
|
||||||
doraCount,
|
doraCount,
|
||||||
pointDeltas,
|
pointDeltas,
|
||||||
};
|
};
|
||||||
|
@ -293,24 +310,27 @@ export class PlayerGameEngine {
|
||||||
n: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } },
|
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) {
|
for (const house of callers) {
|
||||||
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
|
const yakus = calcYakusWithDetail({
|
||||||
house: house,
|
seatWind: house,
|
||||||
handTiles: handTiles[house].map(id => $type(id)),
|
handTiles: handTiles[house].map(id => $type(id)).concat([ronTile]),
|
||||||
huros: this.state.huros[house],
|
huros: this.state.huros[house].map(convertHuroForCalcYaku),
|
||||||
tsumoTile: null,
|
tsumoTile: null,
|
||||||
ronTile: $type(this.state.hoTiles[callee].at(-1)!),
|
ronTile: ronTile,
|
||||||
|
firstTurn: this.state.firstTurnFlags[house],
|
||||||
riichi: this.state.riichis[house],
|
riichi: this.state.riichis[house],
|
||||||
|
doubleRiichi: this.state.doubleRiichis[house],
|
||||||
ippatsu: this.state.ippatsus[house],
|
ippatsu: this.state.ippatsus[house],
|
||||||
}));
|
hotei: this.state.tilesCount == 0,
|
||||||
|
});
|
||||||
const doraCount =
|
const doraCount =
|
||||||
Common.calcOwnedDoraCount(handTiles[house].map(id => $type(id)), this.state.huros[house], this.doras) +
|
Common.calcOwnedDoraCount(handTiles[house].map(id => $type(id)), this.state.huros[house], this.doras) +
|
||||||
Common.calcRedDoraCount(handTiles[house], this.state.huros[house]);
|
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.calcPoint(yakus, house === 'e');
|
||||||
const point = Common.fanToPoint(fans, house === 'e');
|
|
||||||
this.state.points[callee] -= point;
|
this.state.points[callee] -= point;
|
||||||
this.state.points[house] += 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].doraCount = doraCount;
|
||||||
resultMap[house].pointDeltas[callee] = -point;
|
resultMap[house].pointDeltas[callee] = -point;
|
||||||
resultMap[house].pointDeltas[house] = point;
|
resultMap[house].pointDeltas[house] = point;
|
||||||
|
@ -329,7 +349,7 @@ export class PlayerGameEngine {
|
||||||
* @param caller ポンした人
|
* @param caller ポンした人
|
||||||
* @param callee 牌を捨てた人
|
* @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.canPon = null;
|
||||||
|
|
||||||
this.state.hoTiles[callee].pop();
|
this.state.hoTiles[callee].pop();
|
||||||
|
@ -351,7 +371,7 @@ export class PlayerGameEngine {
|
||||||
* @param caller 大明槓した人
|
* @param caller 大明槓した人
|
||||||
* @param callee 牌を捨てた人
|
* @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.canKan = null;
|
||||||
|
|
||||||
this.state.hoTiles[callee].pop();
|
this.state.hoTiles[callee].pop();
|
||||||
|
@ -383,7 +403,7 @@ export class PlayerGameEngine {
|
||||||
* @param caller チーした人
|
* @param caller チーした人
|
||||||
* @param callee 牌を捨てた人
|
* @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.canCii = null;
|
||||||
|
|
||||||
this.state.hoTiles[callee].pop();
|
this.state.hoTiles[callee].pop();
|
||||||
|
|
235
packages/misskey-mahjong/test/engine.ts
Normal file
235
packages/misskey-mahjong/test/engine.ts
Normal file
|
@ -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<number, TileId>;
|
||||||
|
|
||||||
|
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<MasterState, 'tiles' | 'kingTiles' | 'handTiles'> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
70
packages/misskey-mahjong/test/fu.ts
Normal file
70
packages/misskey-mahjong/test/fu.ts
Normal file
|
@ -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',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue