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:
Take-John 2024-08-15 12:29:31 +09:00 committed by GitHub
parent f32b11ba12
commit bf818a6656
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1417 additions and 492 deletions

View 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';
/**
* 41
*/
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];
}

View File

@ -109,21 +109,29 @@ export type House = 'e' | 's' | 'w' | 'n';
*/
export type Huro = {
type: 'pon';
tiles: [TileId, TileId, TileId];
tiles: readonly [TileId, TileId, TileId];
from: House;
} | {
type: 'cii';
tiles: [TileId, TileId, TileId];
tiles: readonly [TileId, TileId, TileId];
from: House;
} | {
type: 'ankan';
tiles: [TileId, TileId, TileId, TileId];
tiles: readonly [TileId, TileId, TileId, TileId];
} | {
type: 'minkan';
tiles: [TileId, TileId, TileId, TileId];
tiles: readonly [TileId, TileId, TileId, TileId];
from: House | null; // null で加槓
};
export type PointFactor = {
isYakuman: false;
fan: number;
} | {
isYakuman: true;
value: number;
}
export const CALL_HURO_TYPES = ['pon', 'cii', 'minkan'] as const;
export const NEXT_TILE_FOR_DORA_MAP: Record<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 CHAR_TILES = ['e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[];
export const YAOCHU_TILES = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[];
export const TERMINAL_TILES = ['m1', 'm9', 'p1', 'p9', 's1', 's9'] as const satisfies TileType[];
const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'];
export function isManzu<T extends TileType>(tile: T): tile is typeof MANZU_TILES[number] {
return MANZU_TILES.includes(tile);
export function includes<A extends ReadonlyArray<unknown>>(array: A, searchElement: unknown): searchElement is A[number] {
return array.includes(searchElement);
}
export function isPinzu<T extends TileType>(tile: T): tile is typeof PINZU_TILES[number] {
return PINZU_TILES.includes(tile);
export function isManzu(tile: TileType): tile is typeof MANZU_TILES[number] {
return includes(MANZU_TILES, tile);
}
export function isSouzu<T extends TileType>(tile: T): tile is typeof SOUZU_TILES[number] {
return SOUZU_TILES.includes(tile);
export function isPinzu(tile: TileType): tile is typeof PINZU_TILES[number] {
return includes(PINZU_TILES, tile);
}
export function isSouzu(tile: TileType): tile is typeof SOUZU_TILES[number] {
return includes(SOUZU_TILES, tile);
}
export function isSameNumberTile(a: TileType, b: TileType): boolean {
@ -328,16 +341,24 @@ export function fanToPoint(fan: number, isParent: boolean): number {
return point;
}
export function calcPoint(factor: PointFactor, isParent: boolean): number {
if (factor.isYakuman) {
return 32000 * factor.value * (isParent ? 1.5 : 1);
} else {
return fanToPoint(factor.fan, isParent);
}
}
export function calcOwnedDoraCount(handTiles: TileType[], huros: Huro[], doras: TileType[]): number {
let count = 0;
for (const t of handTiles) {
if (doras.includes(t)) count++;
}
for (const huro of huros) {
if (huro.type === 'pon' && doras.includes(huro.tile)) count += 3;
if (huro.type === 'cii') count += huro.tiles.filter(t => doras.includes(t)).length;
if (huro.type === 'minkan' && doras.includes(huro.tile)) count += 4;
if (huro.type === 'ankan' && doras.includes(huro.tile)) count += 4;
if (huro.type === 'pon' && includes(doras, huro.tiles[0])) count += 3;
if (huro.type === 'cii') count += huro.tiles.filter(t => includes(doras, t)).length;
if (huro.type === 'minkan' && includes(doras, huro.tiles[0])) count += 4;
if (huro.type === 'ankan' && includes(doras, huro.tiles[0])) count += 4;
}
return count;
}
@ -355,7 +376,7 @@ export function calcRedDoraCount(handTiles: TileId[], huros: Huro[]): number {
return count;
}
export function calcTsumoHoraPointDeltas(house: House, fans: number): Record<House, number> {
export function calcTsumoHoraPointDeltas(house: House, fansOrFactor: number | PointFactor): Record<House, number> {
const isParent = house === 'e';
const deltas: Record<House, number> = {
@ -365,7 +386,7 @@ export function calcTsumoHoraPointDeltas(house: House, fans: number): Record<Hou
n: 0,
};
const point = fanToPoint(fans, isParent);
const point = typeof fansOrFactor == 'number' ? fanToPoint(fansOrFactor, isParent) : calcPoint(fansOrFactor, isParent);
deltas[house] = point;
if (isParent) {
const childPoint = Math.ceil(point / 3);
@ -442,6 +463,10 @@ export function isKotsu(tiles: [TileType, TileType, TileType]): boolean {
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][] = [
['m1', 'm2', 'm3'],
['m2', 'm3', 'm4'],

View File

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

View File

@ -7,7 +7,9 @@ import CRC32 from 'crc-32';
import { TileType, House, Huro, TileId } from './common.js';
import * as Common from './common.js';
import { PlayerState } from './engine.player.js';
import { YAKU_DEFINITIONS } from "./common.yaku.js";
import { calcYakusWithDetail, convertHuroForCalcYaku, YakuData, YakuSet } from './common.yaku.js';
export const INITIAL_POINT = 25000;
//#region syntax suger
function $(tid: TileId): Common.TileInstance {
@ -134,13 +136,33 @@ class StateManager {
pattern.filter(t => hand.includes(t)).length >= 2);
}
public tsumo(): TileId {
const tile = this.$state.tiles.pop();
private withTsumoTile(tile: TileId | undefined, isRinshan: boolean): TileId {
if (tile == null) throw new Error('No tiles left');
if (this.$state.turn == null) throw new Error('Not your turn');
this.$state.handTiles[this.$state.turn].push(tile);
this.$state.rinshanFlags[this.$state.turn] = isRinshan;
return tile;
}
public tsumo(): TileId {
return this.withTsumoTile(this.$state.tiles.pop(), false);
}
public rinshanTsumo(): TileId {
return this.withTsumoTile(this.$state.tiles.shift(), true);
}
public clearFirstTurnAndIppatsus(): void {
this.$state.firstTurnFlags.e = false;
this.$state.firstTurnFlags.s = false;
this.$state.firstTurnFlags.w = false;
this.$state.firstTurnFlags.n = false;
this.$state.ippatsus.e = false;
this.$state.ippatsus.s = false;
this.$state.ippatsus.w = false;
this.$state.ippatsus.n = false;
}
}
export type MasterState = {
@ -178,18 +200,36 @@ export type MasterState = {
w: Huro[];
n: Huro[];
};
firstTurnFlags: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
riichis: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
doubleRiichis: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
ippatsus: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
rinshanFlags: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
}
points: {
e: number;
s: number;
@ -304,7 +344,7 @@ export class MasterGameEngine {
return this.stateManager.turn;
}
public static createInitialState(): MasterState {
public static createInitialState(preset: Partial<MasterState> = {}): MasterState {
const ikasama: TileId[] = [125, 129, 9, 56, 57, 61, 77, 81, 85, 133, 134, 135, 121, 122];
const tiles = shuffle([...Common.TILE_ID_MAP.keys()]);
@ -350,23 +390,41 @@ export class MasterGameEngine {
w: [],
n: [],
},
firstTurnFlags: {
e: true,
s: true,
w: true,
n: true,
},
riichis: {
e: false,
s: false,
w: false,
n: false,
},
doubleRiichis: {
e: false,
s: false,
w: false,
n: false,
},
ippatsus: {
e: false,
s: false,
w: false,
n: false,
},
rinshanFlags: {
e: false,
s: false,
w: false,
n: false,
},
points: {
e: 25000,
s: 25000,
w: 25000,
n: 25000,
e: INITIAL_POINT,
s: INITIAL_POINT,
w: INITIAL_POINT,
n: INITIAL_POINT,
},
turn: 'e',
nextTurnAfterAsking: null,
@ -376,6 +434,7 @@ export class MasterGameEngine {
cii: null,
kan: null,
},
...preset,
};
}
@ -433,8 +492,14 @@ export class MasterGameEngine {
if (riichi) {
tx.$state.riichis[house] = true;
tx.$state.ippatsus[house] = true;
if (tx.$state.firstTurnFlags[house]) {
tx.$state.doubleRiichis[house] = true;
}
}
tx.$state.firstTurnFlags[house] = false;
tx.$state.rinshanFlags[house] = false;
const canRonHouses: House[] = [];
switch (house) {
case 'e':
@ -548,20 +613,17 @@ export class MasterGameEngine {
public commit_kakan(house: House, tid: TileId) {
const tx = this.startTransaction();
const pon = tx.$state.huros[house].find(h => h.type === 'pon' && $type(h.tiles[0]) === $type(tid));
const pon = tx.$state.huros[house].find(h => h.type === 'pon' && $type(h.tiles[0]) === $type(tid)) as Huro & {type: 'pon'};
if (pon == null) throw new Error('No such pon');
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(tid), 1);
const tiles = [tid, ...pon.tiles];
const tiles = [tid, ...pon.tiles] as const;
tx.$state.huros[house].push({ type: 'minkan', tiles: tiles, from: pon.from });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.activatedDorasCount++;
const rinsyan = tx.tsumo();
const rinsyan = tx.rinshanTsumo();
tx.$commit();
@ -587,17 +649,14 @@ export class MasterGameEngine {
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t2), 1);
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t3), 1);
tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t4), 1);
const tiles = [t1, t2, t3, t4];
const tiles = [t1, t2, t3, t4] as const;
tx.$state.huros[house].push({ type: 'ankan', tiles: tiles });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.activatedDorasCount++;
const rinsyan = tx.tsumo();
const rinsyan = tx.rinshanTsumo();
tx.$commit();
@ -611,36 +670,40 @@ export class MasterGameEngine {
*
* @param house
*/
public commit_tsumoHora(house: House) {
public commit_tsumoHora(house: House, doLog = true) {
const tx = this.startTransaction();
if (tx.$state.turn !== house) throw new Error('Not your turn');
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
house: house,
const yakus = calcYakusWithDetail({
seatWind: house,
handTiles: tx.handTileTypes[house],
huros: tx.$state.huros[house],
huros: tx.$state.huros[house].map(convertHuroForCalcYaku),
tsumoTile: tx.handTileTypes[house].at(-1)!,
ronTile: null,
firstTurn: tx.$state.firstTurnFlags[house],
riichi: tx.$state.riichis[house],
doubleRiichi: tx.$state.doubleRiichis[house],
ippatsu: tx.$state.ippatsus[house],
}));
rinshan: tx.$state.rinshanFlags[house],
haitei: tx.$state.tiles.length == 0,
});
const doraCount =
Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) +
Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]);
const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount;
const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans);
const pointDeltas = Common.calcTsumoHoraPointDeltas(house, yakus);
tx.$state.points.e += pointDeltas.e;
tx.$state.points.s += pointDeltas.s;
tx.$state.points.w += pointDeltas.w;
tx.$state.points.n += pointDeltas.n;
console.log('yakus', house, yakus);
if (doLog) console.log('yakus', house, yakus);
tx.$commit();
return {
handTiles: tx.$state.handTiles[house],
tsumoTile: tx.$state.handTiles[house].at(-1)!,
yakus,
};
}
@ -649,7 +712,7 @@ export class MasterGameEngine {
cii: false | 'x__' | '_x_' | '__x';
kan: boolean;
ron: House[];
}) {
}, doLog = true) {
const tx = this.startTransaction();
if (tx.$state.askings.pon == null && tx.$state.askings.cii == null && tx.$state.askings.kan == null && tx.$state.askings.ron == null) throw new Error();
@ -668,26 +731,31 @@ export class MasterGameEngine {
const callers = answers.ron;
const callee = ron.callee;
for (const house of callers) {
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
house: house,
handTiles: tx.handTileTypes[house],
huros: tx.$state.huros[house],
const yakus: { [K in House]?: YakuSet } = Object.fromEntries(callers.map(house => {
const ronTile = tx.hoTileTypes[callee].at(-1)!;
const yakus = calcYakusWithDetail({
seatWind: house,
handTiles: tx.handTileTypes[house].concat([ronTile]),
huros: tx.$state.huros[house].map(convertHuroForCalcYaku),
tsumoTile: null,
ronTile: tx.hoTileTypes[callee].at(-1)!,
ronTile,
firstTurn: tx.$state.firstTurnFlags[house],
riichi: tx.$state.riichis[house],
doubleRiichi: tx.$state.doubleRiichis[house],
ippatsu: tx.$state.ippatsus[house],
}));
hotei: tx.$state.tiles.length == 0,
});
const doraCount =
Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) +
Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]);
const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount;
const point = Common.fanToPoint(fans, house === 'e');
const point = Common.calcPoint(yakus, house === 'e');
tx.$state.points[callee] -= point;
tx.$state.points[house] += point;
console.log('fans point', fans, point);
console.log('yakus', house, yakus);
}
if (doLog) {
console.log('yakus', house, yakus);
}
return [house, yakus] as const;
}));
tx.$commit();
@ -696,6 +764,7 @@ export class MasterGameEngine {
callers: ron.callers,
callee: ron.callee,
turn: null,
yakus,
};
} else if (kan != null && answers.kan) {
// 大明槓
@ -712,17 +781,14 @@ export class MasterGameEngine {
tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t2), 1);
tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t3), 1);
const tiles = [tile, t1, t2, t3];
const tiles = [tile, t1, t2, t3] as const;
tx.$state.huros[kan.caller].push({ type: 'minkan', tiles: tiles, from: kan.callee });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.activatedDorasCount++;
const rinsyan = tx.tsumo();
const rinsyan = tx.rinshanTsumo();
tx.$state.turn = kan.caller;
@ -746,13 +812,10 @@ export class MasterGameEngine {
tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t1), 1);
tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t2), 1);
const tiles = [tile, t1, t2];
const tiles = [tile, t1, t2] as const;
tx.$state.huros[pon.caller].push({ type: 'pon', tiles: tiles, from: pon.callee });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.turn = pon.caller;
@ -816,10 +879,7 @@ export class MasterGameEngine {
tx.$state.huros[cii.caller].push({ type: 'cii', tiles: tiles, from: cii.callee });
tx.$state.ippatsus.e = false;
tx.$state.ippatsus.s = false;
tx.$state.ippatsus.w = false;
tx.$state.ippatsus.n = false;
tx.clearFirstTurnAndIppatsus();
tx.$state.turn = cii.caller;
@ -891,18 +951,36 @@ export class MasterGameEngine {
w: this.$state.huros.w,
n: this.$state.huros.n,
},
firstTurnFlags: {
e: this.$state.firstTurnFlags.e,
s: this.$state.firstTurnFlags.s,
w: this.$state.firstTurnFlags.w,
n: this.$state.firstTurnFlags.n,
},
riichis: {
e: this.$state.riichis.e,
s: this.$state.riichis.s,
w: this.$state.riichis.w,
n: this.$state.riichis.n,
},
doubleRiichis: {
e: this.$state.doubleRiichis.e,
s: this.$state.doubleRiichis.s,
w: this.$state.doubleRiichis.w,
n: this.$state.doubleRiichis.n,
},
ippatsus: {
e: this.$state.ippatsus.e,
s: this.$state.ippatsus.s,
w: this.$state.ippatsus.w,
n: this.$state.ippatsus.n,
},
rinshanFlags: {
e: this.$state.rinshanFlags.e,
s: this.$state.rinshanFlags.s,
w: this.$state.rinshanFlags.w,
n: this.$state.rinshanFlags.n,
},
points: {
e: this.$state.points.e,
s: this.$state.points.s,
@ -911,6 +989,10 @@ export class MasterGameEngine {
},
latestDahaiedTile: null,
turn: this.$state.turn,
canPon: null,
canCii: null,
canKan: null,
canRon: null,
};
}

View File

@ -6,7 +6,7 @@
import CRC32 from 'crc-32';
import { TileType, House, Huro, TileId } from './common.js';
import * as Common from './common.js';
import { YAKU_DEFINITIONS } from './common.yaku.js';
import { calcYakusWithDetail, convertHuroForCalcYaku } from './common.yaku.js';
//#region syntax suger
function $(tid: TileId): Common.TileInstance {
@ -53,18 +53,36 @@ export type PlayerState = {
w: Huro[];
n: Huro[];
};
firstTurnFlags: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
riichis: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
doubleRiichis: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
ippatsus: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
};
rinshanFlags: {
e: boolean;
s: boolean;
w: boolean;
n: boolean;
}
points: {
e: number;
s: number;
@ -80,7 +98,7 @@ export type PlayerState = {
};
export type KyokuResult = {
yakus: { name: string; fan: number; isYakuman: boolean; }[];
yakus: { name: string; fan: number | null; isYakuman: boolean; }[];
doraCount: number;
pointDeltas: { e: number; s: number; w: number; n: number; };
};
@ -241,31 +259,30 @@ export class PlayerGameEngine {
public commit_tsumoHora(house: House, handTiles: TileId[], tsumoTile: TileId): KyokuResult {
console.log('commit_tsumoHora', this.state.turn, house);
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
house: house,
const yakus = calcYakusWithDetail({
seatWind: house,
handTiles: handTiles.map(id => $type(id)),
huros: this.state.huros[house],
huros: this.state.huros[house].map(convertHuroForCalcYaku),
tsumoTile: $type(tsumoTile),
ronTile: null,
firstTurn: this.state.firstTurnFlags[house],
riichi: this.state.riichis[house],
doubleRiichi: this.state.doubleRiichis[house],
ippatsu: this.state.ippatsus[house],
}));
rinshan: this.state.rinshanFlags[house],
haitei: this.state.tilesCount == 0,
});
const doraCount =
Common.calcOwnedDoraCount(handTiles.map(id => $type(id)), this.state.huros[house], this.doras) +
Common.calcRedDoraCount(handTiles, this.state.huros[house]);
const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount;
const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans);
const pointDeltas = Common.calcTsumoHoraPointDeltas(house, yakus);
this.state.points.e += pointDeltas.e;
this.state.points.s += pointDeltas.s;
this.state.points.w += pointDeltas.w;
this.state.points.n += pointDeltas.n;
return {
yakus: yakus.map(yaku => ({
name: yaku.name,
fan: yaku.fan,
isYakuman: yaku.isYakuman,
})),
yakus: yakus.yakus,
doraCount,
pointDeltas,
};
@ -293,24 +310,27 @@ export class PlayerGameEngine {
n: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } },
};
const ronTile = $type(this.state.hoTiles[callee].at(-1)!);
for (const house of callers) {
const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({
house: house,
handTiles: handTiles[house].map(id => $type(id)),
huros: this.state.huros[house],
const yakus = calcYakusWithDetail({
seatWind: house,
handTiles: handTiles[house].map(id => $type(id)).concat([ronTile]),
huros: this.state.huros[house].map(convertHuroForCalcYaku),
tsumoTile: null,
ronTile: $type(this.state.hoTiles[callee].at(-1)!),
ronTile: ronTile,
firstTurn: this.state.firstTurnFlags[house],
riichi: this.state.riichis[house],
doubleRiichi: this.state.doubleRiichis[house],
ippatsu: this.state.ippatsus[house],
}));
hotei: this.state.tilesCount == 0,
});
const doraCount =
Common.calcOwnedDoraCount(handTiles[house].map(id => $type(id)), this.state.huros[house], this.doras) +
Common.calcRedDoraCount(handTiles[house], this.state.huros[house]);
const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount;
const point = Common.fanToPoint(fans, house === 'e');
const point = Common.calcPoint(yakus, house === 'e');
this.state.points[callee] -= point;
this.state.points[house] += point;
resultMap[house].yakus = yakus.map(yaku => ({ name: yaku.name, fan: yaku.fan, isYakuman: yaku.isYakuman }));
resultMap[house].yakus = yakus.yakus;
resultMap[house].doraCount = doraCount;
resultMap[house].pointDeltas[callee] = -point;
resultMap[house].pointDeltas[house] = point;
@ -329,7 +349,7 @@ export class PlayerGameEngine {
* @param caller
* @param callee
*/
public commit_pon(caller: House, callee: House, tiles: TileId[]) {
public commit_pon(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId]) {
this.state.canPon = null;
this.state.hoTiles[callee].pop();
@ -351,7 +371,7 @@ export class PlayerGameEngine {
* @param caller
* @param callee
*/
public commit_kan(caller: House, callee: House, tiles: TileId[], rinsyan: TileId) {
public commit_kan(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId, TileId], rinsyan: TileId) {
this.state.canKan = null;
this.state.hoTiles[callee].pop();
@ -383,7 +403,7 @@ export class PlayerGameEngine {
* @param caller
* @param callee
*/
public commit_cii(caller: House, callee: House, tiles: TileId[]) {
public commit_cii(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId]) {
this.state.canCii = null;
this.state.hoTiles[callee].pop();

View 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番目0nが負の場合-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,
});
});
});

View 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