feature(mahjong): 搶槓/ドラ以外の麻雀の役を実装 (#14346)
* ビルドによる自動的なソース更新 * 麻雀関連のキーバリューペアを追加 * 役の定義をまとめてエクスポート * タイポ修正 * Revert "麻雀関連のキーバリューペアを追加" This reverts commit c349cdf70c69af89d93ed7db035efaaacf2c2785. * misskey-jsのビルドによる自動更新 * 型エラーに対処 * riichiがtrueの場合に門前であるかを確認 * EnvForCalcYakuのhouseプロパティを廃止 * 風牌の役の共通部分をクラスで定義 * タイポ修正 * 役牌をクラスで共通化 * 一盃口と二盃口のテストを通す * 一盃口・二盃口判定関数の調整 * 一気通貫の判定にチーによる順子も考慮する * 混全帯幺九の実装 * 純全帯幺九の実装 * 七対子の実装とテストの修正 * tsumoTileまたはronTileを必須に * 待ちを確認して平和の判定を可能に * 三暗刻と四暗刻、四暗刻単騎の実装 * 四暗刻であるために通常の役を判定できない牌姿のテストを修正 * 混老頭と清老頭を実装 * 三槓子と四槓子を実装 * 平和の実装とテストを修正 * 小三元のテストを修正 * 国士無双に対子の確認を追加 * 国士無双十三面待ちを実装し、テストを修正 * 一部の役の七対子形を認め、テストを追加 * 手牌の数を確認 * 役の定義をカプセル化して型エラーの対処 * ツモ・ロンの判定を修正 * calcYakusの引数のhandTilesを修正 * calcYakusに渡す風をseatWindに修正 * 嶺上開花の実装 * 海底摸月の実装 * FourMentsuOneJyantouWithWait型の作成 * 河底撈魚の実装 * ダブル立直の実装 * 天和・地和の実装 * エンジンのテストを作成 * エンジンによる地和のテストを追加 * 嶺上開花のテスト * ライセンスの記述を追加 * ダブル立直一発ツモのテスト * ダブル立直海底ツモのテスト * ダブル立直河底のテスト * 役満も処理できるように * 点数のテスト * 打牌時にrinshanFlags[house]をfalseに * 七対子形の字一色を認める * typo
This commit is contained in:
parent
f32b11ba12
commit
bf818a6656
8 changed files with 1417 additions and 492 deletions
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 = {
|
||||
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'],
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { CALL_HURO_TYPES, CHAR_TILES, FourMentsuOneJyantou, House, MANZU_TILES, PINZU_TILES, SOUZU_TILES, TileType, YAOCHU_TILES, TILE_TYPES, analyzeFourMentsuOneJyantou, isShuntu, isManzu, isPinzu, isSameNumberTile, isSouzu, isKotsu } from './common.js';
|
||||
import { CALL_HURO_TYPES, CHAR_TILES, FourMentsuOneJyantou, House, MANZU_TILES, PINZU_TILES, SOUZU_TILES, TileType, YAOCHU_TILES, TILE_TYPES, analyzeFourMentsuOneJyantou, isShuntu, isManzu, isPinzu, isSameNumberTile, isSouzu, isKotsu, includes, TERMINAL_TILES, mentsuEquals, Huro, TILE_ID_MAP } from './common.js';
|
||||
import { calcWaitPatterns, isRyanmen, isToitsu, FourMentsuOneJyantouWithWait } from './common.fu.js';
|
||||
|
||||
const RYUISO_TILES: TileType[] = ['s2', 's3', 's4', 's6', 's8', 'hatsu'];
|
||||
const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'];
|
||||
|
@ -67,11 +68,27 @@ export const YAKUMAN_NAMES = [
|
|||
'chiho',
|
||||
] as const;
|
||||
|
||||
export type YakuName = typeof NORMAL_YAKU_NAMES[number] | typeof YAKUMAN_NAMES[number];
|
||||
type NormalYakuName = typeof NORMAL_YAKU_NAMES[number]
|
||||
|
||||
type YakumanName = typeof YAKUMAN_NAMES[number];
|
||||
|
||||
export type YakuName = NormalYakuName | YakumanName;
|
||||
|
||||
export type HuroForCalcYaku = {
|
||||
type: 'pon';
|
||||
tile: TileType;
|
||||
} | {
|
||||
type: 'cii';
|
||||
tiles: [TileType, TileType, TileType];
|
||||
} | {
|
||||
type: 'ankan';
|
||||
tile: TileType;
|
||||
} | {
|
||||
type: 'minkan';
|
||||
tile: TileType;
|
||||
};
|
||||
|
||||
export type EnvForCalcYaku = {
|
||||
house: House;
|
||||
|
||||
/**
|
||||
* 和了る人の手牌(副露牌は含まず、ツモ、ロン牌は含む)
|
||||
*/
|
||||
|
@ -80,72 +97,221 @@ export type EnvForCalcYaku = {
|
|||
/**
|
||||
* 河
|
||||
*/
|
||||
hoTiles: TileType[];
|
||||
hoTiles?: TileType[];
|
||||
|
||||
/**
|
||||
* 副露
|
||||
*/
|
||||
huros: ({
|
||||
type: 'pon';
|
||||
tile: TileType;
|
||||
} | {
|
||||
type: 'cii';
|
||||
tiles: [TileType, TileType, TileType];
|
||||
} | {
|
||||
type: 'ankan';
|
||||
tile: TileType;
|
||||
} | {
|
||||
type: 'minkan';
|
||||
tile: TileType;
|
||||
})[];
|
||||
|
||||
tsumoTile: TileType;
|
||||
ronTile: TileType;
|
||||
huros: HuroForCalcYaku[];
|
||||
|
||||
/**
|
||||
* 場風
|
||||
*/
|
||||
fieldWind: House;
|
||||
fieldWind?: House;
|
||||
|
||||
/**
|
||||
* 自風
|
||||
*/
|
||||
seatWind: House;
|
||||
seatWind?: House;
|
||||
|
||||
/**
|
||||
* 局が始まってから誰の副露もない一巡目かどうか
|
||||
*/
|
||||
firstTurn?: boolean;
|
||||
|
||||
/**
|
||||
* リーチしたかどうか
|
||||
*/
|
||||
riichi: boolean;
|
||||
riichi?: boolean;
|
||||
|
||||
/**
|
||||
* 一巡目以内かどうか
|
||||
* 誰の副露もない一巡目でリーチしたかどうか
|
||||
*/
|
||||
ippatsu: boolean;
|
||||
doubleRiichi?: boolean;
|
||||
|
||||
/**
|
||||
* リーチしてから誰の副露もない一巡目以内かどうか
|
||||
*/
|
||||
ippatsu?: boolean;
|
||||
} & ({
|
||||
tsumoTile: TileType;
|
||||
ronTile?: null;
|
||||
|
||||
/**
|
||||
* 嶺上牌のツモか
|
||||
*/
|
||||
rinshan?: boolean;
|
||||
|
||||
/**
|
||||
* 海底牌か
|
||||
*/
|
||||
haitei?: boolean;
|
||||
} | {
|
||||
tsumoTile?: null;
|
||||
ronTile: TileType;
|
||||
|
||||
/**
|
||||
* 河底牌か
|
||||
*/
|
||||
hotei?: boolean;
|
||||
});
|
||||
|
||||
interface YakuDataBase {
|
||||
name: YakuName;
|
||||
upper?: YakuName | null;
|
||||
fan?: number | null;
|
||||
isYakuman?: boolean;
|
||||
}
|
||||
|
||||
interface NormalYakuData extends YakuDataBase {
|
||||
name: NormalYakuName;
|
||||
fan: number;
|
||||
isYakuman?: false;
|
||||
kuisagari?: boolean;
|
||||
}
|
||||
|
||||
interface YakumanData extends YakuDataBase {
|
||||
name: YakumanName;
|
||||
isYakuman: true;
|
||||
isDoubleYakuman?: boolean;
|
||||
}
|
||||
|
||||
export type YakuData = Required<NormalYakuData> | Required<YakumanData>;
|
||||
|
||||
abstract class YakuSetBase<IsYakuman extends boolean> {
|
||||
public readonly isYakuman: IsYakuman;
|
||||
|
||||
public readonly yakus: YakuData[];
|
||||
|
||||
public get yakuNames(): YakuName[] {
|
||||
return this.yakus.map(yaku => yaku.name);
|
||||
}
|
||||
|
||||
constructor(isYakuman: IsYakuman, yakus: YakuData[]) {
|
||||
this.isYakuman = isYakuman;
|
||||
this.yakus = yakus;
|
||||
}
|
||||
}
|
||||
|
||||
class NormalYakuSet extends YakuSetBase<false> {
|
||||
public readonly isMenzen: boolean;
|
||||
|
||||
public readonly fan: number;
|
||||
|
||||
constructor(isMenzen: boolean, yakus: Required<NormalYakuData>[]) {
|
||||
super(false, yakus);
|
||||
this.isMenzen = isMenzen;
|
||||
this.fan = yakus.reduce((fan, yaku) => fan + (!isMenzen && yaku.kuisagari ? yaku.fan - 1 : yaku.fan), 0);
|
||||
}
|
||||
}
|
||||
|
||||
class YakumanSet extends YakuSetBase<true> {
|
||||
/**
|
||||
* 何倍役満か
|
||||
*/
|
||||
public readonly value: number;
|
||||
|
||||
constructor(yakus: Required<YakumanData>[]) {
|
||||
super(true, yakus);
|
||||
this.value = yakus.reduce((value, yaku) => value + (yaku.isDoubleYakuman ? 2 : 1), 0);
|
||||
}
|
||||
}
|
||||
|
||||
export type YakuSet = NormalYakuSet | YakumanSet;
|
||||
|
||||
type YakuDefinitionBase = {
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => boolean;
|
||||
};
|
||||
|
||||
type YakuDefiniyion = {
|
||||
name: YakuName;
|
||||
upper?: YakuName;
|
||||
fan?: number;
|
||||
isYakuman?: boolean;
|
||||
isDoubleYakuman?: boolean;
|
||||
kuisagari?: boolean;
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => boolean;
|
||||
};
|
||||
type NormalYakuDefinition = YakuDefinitionBase & NormalYakuData;
|
||||
|
||||
type YakumanDefinition = YakuDefinitionBase & YakumanData;
|
||||
|
||||
function countTiles(tiles: TileType[], target: TileType): number {
|
||||
return tiles.filter(t => t === target).length;
|
||||
}
|
||||
|
||||
export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
||||
class Yakuhai implements NormalYakuDefinition {
|
||||
readonly name: NormalYakuName;
|
||||
|
||||
readonly fan = 1;
|
||||
|
||||
readonly isYakuman = false;
|
||||
|
||||
readonly tile: typeof CHAR_TILES[number];
|
||||
|
||||
constructor(name: NormalYakuName, house: typeof CHAR_TILES[number]) {
|
||||
this.name = name;
|
||||
this.tile = house;
|
||||
}
|
||||
|
||||
calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return (
|
||||
(countTiles(state.handTiles, this.tile) >= 3) ||
|
||||
(state.huros.some(huro =>
|
||||
huro.type === 'pon' ? huro.tile === this.tile :
|
||||
huro.type === 'ankan' ? huro.tile === this.tile :
|
||||
huro.type === 'minkan' ? huro.tile === this.tile :
|
||||
false))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FieldWind extends Yakuhai {
|
||||
calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean {
|
||||
return super.calc(state, fourMentsuOneJyantou) && state.fieldWind === this.tile;
|
||||
}
|
||||
}
|
||||
|
||||
class SeatWind extends Yakuhai {
|
||||
calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean {
|
||||
return super.calc(state, fourMentsuOneJyantou) && state.seatWind === this.tile;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2つの同じ面子の組を数える (一盃口なら1、二盃口なら2)
|
||||
*/
|
||||
function countIndenticalMentsuPairs(mentsus: [TileType, TileType, TileType][]) {
|
||||
let result = 0;
|
||||
const singleMentsus: [TileType, TileType, TileType][] = [];
|
||||
loop: for (const mentsu of mentsus) {
|
||||
for (let i = 0 ; i < singleMentsus.length ; i++) {
|
||||
if (mentsuEquals(mentsu, singleMentsus[i])) {
|
||||
result++;
|
||||
singleMentsus.splice(i, 1);
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
singleMentsus.push(mentsu);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暗刻の数を数える (三暗刻なら3、四暗刻なら4)
|
||||
*/
|
||||
function countAnkos(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait) {
|
||||
let ankans = state.huros.filter(huro => huro.type == 'ankan').length;
|
||||
const handKotsus = fourMentsuOneJyantou.mentsus.filter(mentsu => isKotsu(mentsu)).length;
|
||||
|
||||
// ロンによりできた刻子は暗刻ではない
|
||||
if (state.ronTile != null && fourMentsuOneJyantou.waitedFor == 'mentsu' && isToitsu(fourMentsuOneJyantou.waitedTaatsu)) {
|
||||
return ankans + handKotsus - 1;
|
||||
}
|
||||
|
||||
return ankans + handKotsus;
|
||||
}
|
||||
|
||||
export const NORMAL_YAKU_DEFINITIONS: NormalYakuDefinition[] = [{
|
||||
name: 'tsumo',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
// 面前じゃないとダメ
|
||||
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
|
||||
// 門前じゃないとダメ
|
||||
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
|
||||
|
||||
return state.tsumoTile != null;
|
||||
},
|
||||
|
@ -154,173 +320,67 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
return state.riichi;
|
||||
return !state.doubleRiichi && (state.riichi ?? false);
|
||||
},
|
||||
}, {
|
||||
name: 'double-riichi',
|
||||
fan: 2,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku) => {
|
||||
return state.doubleRiichi ?? false;
|
||||
}
|
||||
}, {
|
||||
name: 'ippatsu',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
return state.ippatsu;
|
||||
return state.ippatsu ?? false;
|
||||
},
|
||||
}, {
|
||||
name: 'red',
|
||||
name: 'rinshan',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return (
|
||||
(countTiles(state.handTiles, 'chun') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 'chun' :
|
||||
huro.type === 'ankan' ? huro.tile === 'chun' :
|
||||
huro.type === 'minkan' ? huro.tile === 'chun' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
return (state.tsumoTile != null && state.rinshan) ?? false;
|
||||
}
|
||||
}, {
|
||||
name: 'white',
|
||||
name: 'haitei',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return (
|
||||
(countTiles(state.handTiles, 'haku') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 'haku' :
|
||||
huro.type === 'ankan' ? huro.tile === 'haku' :
|
||||
huro.type === 'minkan' ? huro.tile === 'haku' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
return (state.tsumoTile != null && state.haitei) ?? false;
|
||||
}
|
||||
}, {
|
||||
name: 'green',
|
||||
name: 'hotei',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return (
|
||||
(countTiles(state.handTiles, 'hatsu') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 'hatsu' :
|
||||
huro.type === 'ankan' ? huro.tile === 'hatsu' :
|
||||
huro.type === 'minkan' ? huro.tile === 'hatsu' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
}, {
|
||||
name: 'field-wind-e',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return state.fieldWind === 'e' && (
|
||||
(countTiles(state.handTiles, 'e') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 'e' :
|
||||
huro.type === 'ankan' ? huro.tile === 'e' :
|
||||
huro.type === 'minkan' ? huro.tile === 'e' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
}, {
|
||||
name: 'field-wind-s',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return state.fieldWind === 's' && (
|
||||
(countTiles(state.handTiles, 's') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 's' :
|
||||
huro.type === 'ankan' ? huro.tile === 's' :
|
||||
huro.type === 'minkan' ? huro.tile === 's' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
}, {
|
||||
name: 'seat-wind-e',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return state.house === 'e' && (
|
||||
(countTiles(state.handTiles, 'e') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 'e' :
|
||||
huro.type === 'ankan' ? huro.tile === 'e' :
|
||||
huro.type === 'minkan' ? huro.tile === 'e' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
}, {
|
||||
name: 'seat-wind-s',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return state.house === 's' && (
|
||||
(countTiles(state.handTiles, 's') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 's' :
|
||||
huro.type === 'ankan' ? huro.tile === 's' :
|
||||
huro.type === 'minkan' ? huro.tile === 's' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
}, {
|
||||
name: 'seat-wind-w',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return state.house === 'w' && (
|
||||
(countTiles(state.handTiles, 'w') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 'w' :
|
||||
huro.type === 'ankan' ? huro.tile === 'w' :
|
||||
huro.type === 'minkan' ? huro.tile === 'w' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
}, {
|
||||
name: 'seat-wind-n',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return state.house === 'n' && (
|
||||
(countTiles(state.handTiles, 'n') >= 3) ||
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? huro.tile === 'n' :
|
||||
huro.type === 'ankan' ? huro.tile === 'n' :
|
||||
huro.type === 'minkan' ? huro.tile === 'n' :
|
||||
false).length >= 3)
|
||||
);
|
||||
},
|
||||
}, {
|
||||
return (state.ronTile != null && state.hotei) ?? false;
|
||||
}
|
||||
},
|
||||
new Yakuhai('red', 'chun'),
|
||||
new Yakuhai('white', 'haku'),
|
||||
new Yakuhai('green', 'hatsu'),
|
||||
new FieldWind('field-wind-e', 'e'),
|
||||
new FieldWind('field-wind-s', 's'),
|
||||
new FieldWind('field-wind-w', 'w'),
|
||||
new FieldWind('field-wind-n', 'n'),
|
||||
new SeatWind('seat-wind-e', 'e'),
|
||||
new SeatWind('seat-wind-s', 's'),
|
||||
new SeatWind('seat-wind-w', 'w'),
|
||||
new SeatWind('seat-wind-n', 'n'),
|
||||
{
|
||||
name: 'tanyao',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
return (
|
||||
(!state.handTiles.some(t => YAOCHU_TILES.includes(t))) &&
|
||||
(!state.handTiles.some(t => includes(YAOCHU_TILES, t))) &&
|
||||
(state.huros.filter(huro =>
|
||||
huro.type === 'pon' ? YAOCHU_TILES.includes(huro.tile) :
|
||||
huro.type === 'ankan' ? YAOCHU_TILES.includes(huro.tile) :
|
||||
huro.type === 'minkan' ? YAOCHU_TILES.includes(huro.tile) :
|
||||
huro.type === 'cii' ? huro.tiles.some(t2 => YAOCHU_TILES.includes(t2)) :
|
||||
huro.type === 'pon' ? includes(YAOCHU_TILES, huro.tile) :
|
||||
huro.type === 'ankan' ? includes(YAOCHU_TILES, huro.tile) :
|
||||
huro.type === 'minkan' ? includes(YAOCHU_TILES, huro.tile) :
|
||||
huro.type === 'cii' ? huro.tiles.some(t2 => includes(YAOCHU_TILES, t2)) :
|
||||
false).length === 0)
|
||||
);
|
||||
},
|
||||
|
@ -328,15 +388,16 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
name: 'pinfu',
|
||||
fan: 1,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
// 面前じゃないとダメ
|
||||
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
|
||||
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
|
||||
// 三元牌はダメ
|
||||
if (state.handTiles.some(t => ['haku', 'hatsu', 'chun'].includes(t))) return false;
|
||||
|
||||
// TODO: 両面待ちかどうか
|
||||
// 両面待ちかどうか
|
||||
if (!(fourMentsuOneJyantou != null && fourMentsuOneJyantou.waitedFor == 'mentsu' && isRyanmen(fourMentsuOneJyantou.waitedTaatsu))) return false;
|
||||
|
||||
// 風牌判定(役牌でなければOK)
|
||||
if (fourMentsuOneJyantou.head === state.seatWind) return false;
|
||||
|
@ -353,20 +414,18 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
isYakuman: false,
|
||||
kuisagari: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
const tiles = state.handTiles;
|
||||
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
|
||||
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
|
||||
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
|
||||
let charCount = tiles.filter(t => CHAR_TILES.includes(t)).length;
|
||||
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||
let charCount = tiles.filter(t => includes(CHAR_TILES, t)).length;
|
||||
|
||||
for (const huro of state.huros) {
|
||||
const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile];
|
||||
manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length;
|
||||
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
|
||||
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
|
||||
charCount += huroTiles.filter(t => CHAR_TILES.includes(t)).length;
|
||||
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||
charCount += huroTiles.filter(t => includes(CHAR_TILES, t)).length;
|
||||
}
|
||||
|
||||
if (manzuCount > 0 && pinzuCount > 0) return false;
|
||||
|
@ -382,20 +441,18 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
isYakuman: false,
|
||||
kuisagari: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
const tiles = state.handTiles;
|
||||
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
|
||||
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
|
||||
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
|
||||
let charCount = tiles.filter(t => CHAR_TILES.includes(t)).length;
|
||||
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||
let charCount = tiles.filter(t => includes(CHAR_TILES, t)).length;
|
||||
|
||||
for (const huro of state.huros) {
|
||||
const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile];
|
||||
manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length;
|
||||
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
|
||||
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
|
||||
charCount += huroTiles.filter(t => CHAR_TILES.includes(t)).length;
|
||||
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||
charCount += huroTiles.filter(t => includes(CHAR_TILES, t)).length;
|
||||
}
|
||||
|
||||
if (charCount > 0) return false;
|
||||
|
@ -413,12 +470,23 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
// 面前じゃないとダメ
|
||||
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
|
||||
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
|
||||
|
||||
// 同じ順子が2つあるか?
|
||||
return fourMentsuOneJyantou.mentsus.some((mentsu) =>
|
||||
fourMentsuOneJyantou.mentsus.filter((mentsu2) =>
|
||||
mentsu2[0] === mentsu[0] && mentsu2[1] === mentsu[1] && mentsu2[2] === mentsu[2]).length >= 2);
|
||||
return countIndenticalMentsuPairs(fourMentsuOneJyantou.mentsus) == 1;
|
||||
},
|
||||
}, {
|
||||
name: 'ryampeko',
|
||||
fan: 3,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
// 面前じゃないとダメ
|
||||
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
|
||||
|
||||
// 2つの同じ順子が2組あるか?
|
||||
return countIndenticalMentsuPairs(fourMentsuOneJyantou.mentsus) == 2;
|
||||
},
|
||||
}, {
|
||||
name: 'toitoi',
|
||||
|
@ -440,9 +508,25 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
name: 'sananko',
|
||||
fan: 2,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
|
||||
return fourMentsuOneJyantou != null && countAnkos(state, fourMentsuOneJyantou) == 3;
|
||||
},
|
||||
}, {
|
||||
name: 'honroto',
|
||||
fan: 2,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku) => {
|
||||
return state.huros.every(huro => huro.type != 'cii' && includes(YAOCHU_TILES, huro.tile)) &&
|
||||
state.handTiles.every(tile => includes(YAOCHU_TILES, tile));
|
||||
}
|
||||
}, {
|
||||
name: 'sankantsu',
|
||||
fan: 2,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
return fourMentsuOneJyantou != null &&
|
||||
state.huros.filter(huro => huro.type == 'ankan' || huro.type == 'minkan').length == 3;
|
||||
}
|
||||
}, {
|
||||
name: 'sanshoku-dojun',
|
||||
fan: 2,
|
||||
|
@ -520,6 +604,7 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
const shuntsus = fourMentsuOneJyantou.mentsus.filter(tiles => isShuntu(tiles));
|
||||
shuntsus.push(...state.huros.filter(huro => huro.type == 'cii').map(huro => huro.tiles));
|
||||
|
||||
if (shuntsus.some(tiles => tiles[0] === 'm1' && tiles[1] === 'm2' && tiles[2] === 'm3')) {
|
||||
if (shuntsus.some(tiles => tiles[0] === 'm4' && tiles[1] === 'm5' && tiles[2] === 'm6')) {
|
||||
|
@ -545,11 +630,63 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
|
||||
return false;
|
||||
},
|
||||
}, {
|
||||
name: 'chanta',
|
||||
fan: 2,
|
||||
isYakuman: false,
|
||||
kuisagari: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
const { head, mentsus } = fourMentsuOneJyantou;
|
||||
const { huros } = state;
|
||||
|
||||
// 雀頭は幺九牌じゃないとダメ
|
||||
if (!includes(YAOCHU_TILES, head)) return false;
|
||||
|
||||
// 順子は1つ以上じゃないとダメ
|
||||
if (!mentsus.some(mentsu => isShuntu(mentsu))) return false;
|
||||
|
||||
// いずれかの雀頭か面子に字牌を含まないとダメ
|
||||
if (!(includes(CHAR_TILES, head) ||
|
||||
mentsus.some(mentsu => includes(CHAR_TILES, mentsu[0])) ||
|
||||
huros.some(huro => huro.type != 'cii' && includes(CHAR_TILES, huro.tile)))) return false;
|
||||
|
||||
// 全ての面子に幺九牌が含まれる
|
||||
return (mentsus.every(mentsu => mentsu.some(tile => includes(YAOCHU_TILES, tile))) &&
|
||||
huros.every(huro => huro.type == 'cii' ?
|
||||
huro.tiles.some(tile => includes(YAOCHU_TILES, tile)) :
|
||||
includes(YAOCHU_TILES, huro.tile)));
|
||||
},
|
||||
}, {
|
||||
name: 'junchan',
|
||||
fan: 3,
|
||||
isYakuman: false,
|
||||
kuisagari: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
const { head, mentsus } = fourMentsuOneJyantou;
|
||||
const { huros } = state;
|
||||
|
||||
// 雀頭は老頭牌じゃないとダメ
|
||||
if (!includes(TERMINAL_TILES, head)) return false;
|
||||
|
||||
// 順子は1つ以上じゃないとダメ
|
||||
if (!mentsus.some(mentsu => isShuntu(mentsu))) return false;
|
||||
|
||||
// 全ての面子に老頭牌が含まれる
|
||||
return (mentsus.every(mentsu => mentsu.some(tile => includes(TERMINAL_TILES, tile))) &&
|
||||
huros.every(huro => huro.type == 'cii' ?
|
||||
huro.tiles.some(tile => includes(TERMINAL_TILES, tile)) :
|
||||
includes(TERMINAL_TILES, huro.tile)));
|
||||
},
|
||||
}, {
|
||||
name: 'chitoitsu',
|
||||
fan: 2,
|
||||
isYakuman: false,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou != null) return false;
|
||||
if (state.huros.length > 0) return false;
|
||||
const countMap = new Map<TileType, number>();
|
||||
for (const tile of state.handTiles) {
|
||||
|
@ -587,7 +724,21 @@ export const NORMAL_YAKU_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
},
|
||||
}];
|
||||
|
||||
export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
||||
export const YAKUMAN_DEFINITIONS: YakumanDefinition[] = [{
|
||||
name: 'suanko-tanki',
|
||||
isYakuman: true,
|
||||
isDoubleYakuman: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
|
||||
return fourMentsuOneJyantou != null && fourMentsuOneJyantou.waitedFor == 'head' && countAnkos(state, fourMentsuOneJyantou) == 4;
|
||||
}
|
||||
}, {
|
||||
name: 'suanko',
|
||||
isYakuman: true,
|
||||
upper: 'suanko-tanki',
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => {
|
||||
return fourMentsuOneJyantou != null && countAnkos(state, fourMentsuOneJyantou) == 4;
|
||||
}
|
||||
}, {
|
||||
name: 'daisangen',
|
||||
isYakuman: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
|
@ -656,19 +807,17 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
}, {
|
||||
name: 'tsuiso',
|
||||
isYakuman: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
calc: (state: EnvForCalcYaku) => {
|
||||
const tiles = state.handTiles;
|
||||
let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length;
|
||||
let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length;
|
||||
let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length;
|
||||
let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||
let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||
let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||
|
||||
for (const huro of state.huros) {
|
||||
const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile];
|
||||
manzuCount += huroTiles.filter(t => MANZU_TILES.includes(t)).length;
|
||||
pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length;
|
||||
souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length;
|
||||
manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length;
|
||||
pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length;
|
||||
souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length;
|
||||
}
|
||||
|
||||
if (manzuCount > 0 || pinzuCount > 0 || souzuCount > 0) return false;
|
||||
|
@ -690,6 +839,21 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
|
||||
return true;
|
||||
},
|
||||
}, {
|
||||
name: 'chinroto',
|
||||
isYakuman: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
return fourMentsuOneJyantou != null &&
|
||||
state.huros.every(huro => huro.type != 'cii' && includes(TERMINAL_TILES, huro.tile)) &&
|
||||
state.handTiles.every(tile => includes(TERMINAL_TILES, tile));
|
||||
}
|
||||
}, {
|
||||
name: 'sukantsu',
|
||||
isYakuman: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
return fourMentsuOneJyantou != null &&
|
||||
state.huros.filter(huro => huro.type == 'ankan' || huro.type == 'minkan').length == 4;
|
||||
}
|
||||
}, {
|
||||
name: 'churen-9',
|
||||
isYakuman: true,
|
||||
|
@ -698,9 +862,12 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
// 面前じゃないとダメ
|
||||
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
|
||||
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
|
||||
|
||||
const agariTile = state.tsumoTile ?? state.ronTile;
|
||||
if (agariTile == null) {
|
||||
return false;
|
||||
}
|
||||
const tempaiTiles = [...state.handTiles];
|
||||
tempaiTiles.splice(state.handTiles.indexOf(agariTile), 1);
|
||||
|
||||
|
@ -734,7 +901,7 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
if (fourMentsuOneJyantou == null) return false;
|
||||
|
||||
// 面前じゃないとダメ
|
||||
if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false;
|
||||
if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false;
|
||||
|
||||
if (isManzu(state.handTiles[0])) {
|
||||
if ((countTiles(state.handTiles, 'm1') === 3) && (countTiles(state.handTiles, 'm9') === 3)) {
|
||||
|
@ -758,44 +925,120 @@ export const YAKUMAN_DEFINITIONS: YakuDefiniyion[] = [{
|
|||
|
||||
return false;
|
||||
},
|
||||
}, {
|
||||
name: 'kokushi-13',
|
||||
isYakuman: true,
|
||||
isDoubleYakuman: true,
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
const agariTile = state.tsumoTile ?? state.ronTile;
|
||||
return KOKUSHI_TILES.every(t => state.handTiles.includes(t)) && countTiles(state.handTiles, agariTile) == 2;
|
||||
}
|
||||
}, {
|
||||
name: 'kokushi',
|
||||
isYakuman: true,
|
||||
upper: 'kokushi-13',
|
||||
calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => {
|
||||
return KOKUSHI_TILES.every(t => state.handTiles.includes(t));
|
||||
return KOKUSHI_TILES.every(t => state.handTiles.includes(t)) && KOKUSHI_TILES.some(t => countTiles(state.handTiles, t) == 2);
|
||||
},
|
||||
}, {
|
||||
name: 'tenho',
|
||||
isYakuman: true,
|
||||
calc: (state: EnvForCalcYaku) => {
|
||||
return (state.firstTurn ?? false) && state.tsumoTile != null && state.seatWind == 'e';
|
||||
}
|
||||
}, {
|
||||
name: 'chiho',
|
||||
isYakuman: true,
|
||||
calc: (state: EnvForCalcYaku) => {
|
||||
return (state.firstTurn ?? false) && state.tsumoTile != null && state.seatWind != 'e';
|
||||
}
|
||||
}];
|
||||
|
||||
export const YAKU_DEFINITIONS = NORMAL_YAKU_DEFINITIONS.concat(YAKUMAN_DEFINITIONS);
|
||||
export function convertHuroForCalcYaku(huro: Huro): HuroForCalcYaku {
|
||||
switch (huro.type) {
|
||||
case 'pon':
|
||||
case 'ankan':
|
||||
case 'minkan':
|
||||
return {
|
||||
type: huro.type,
|
||||
tile: TILE_ID_MAP.get(huro.tiles[0])!.t,
|
||||
}
|
||||
case 'cii':
|
||||
return {
|
||||
type: 'cii',
|
||||
tiles: huro.tiles.map(tile => TILE_ID_MAP.get(tile)!.t) as [TileType, TileType, TileType],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const NORMAL_YAKU_DATA_MAP = new Map<NormalYakuName, Required<NormalYakuData>>(
|
||||
NORMAL_YAKU_DEFINITIONS.map(yaku => [yaku.name, {
|
||||
name: yaku.name,
|
||||
upper: yaku.upper ?? null,
|
||||
fan: yaku.fan,
|
||||
isYakuman: false,
|
||||
kuisagari: yaku.kuisagari ?? false,
|
||||
}] as const)
|
||||
);
|
||||
|
||||
const YAKUMAN_DATA_MAP = new Map<YakuName, Required<YakumanData>>(
|
||||
YAKUMAN_DEFINITIONS.map(yaku => [yaku.name, {
|
||||
name: yaku.name,
|
||||
upper: yaku.upper ?? null,
|
||||
fan: null,
|
||||
isYakuman: true,
|
||||
isDoubleYakuman: yaku.isDoubleYakuman ?? false,
|
||||
}])
|
||||
);
|
||||
|
||||
export function calcYakusWithDetail(state: EnvForCalcYaku): YakuSet {
|
||||
if (state.riichi && state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type)) ) {
|
||||
throw new TypeError('Invalid riichi state with call huros');
|
||||
}
|
||||
|
||||
const agariTile = state.tsumoTile ?? state.ronTile;
|
||||
if (!state.handTiles.includes(agariTile)) {
|
||||
throw new TypeError('Agari tile not included in hand tiles');
|
||||
}
|
||||
|
||||
if (state.handTiles.length + state.huros.length * 3 != 14) {
|
||||
throw new TypeError('Invalid tile count');
|
||||
}
|
||||
|
||||
export function calcYakus(state: EnvForCalcYaku): YakuName[] {
|
||||
const oneHeadFourMentsuPatterns: (FourMentsuOneJyantou | null)[] = analyzeFourMentsuOneJyantou(state.handTiles);
|
||||
if (oneHeadFourMentsuPatterns.length === 0) oneHeadFourMentsuPatterns.push(null);
|
||||
|
||||
const yakumanPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => {
|
||||
const matchedYakus: YakuDefiniyion[] = [];
|
||||
for (const yakuDef of YAKUMAN_DEFINITIONS) {
|
||||
if (yakuDef.upper && matchedYakus.some(yaku => yaku.name === yakuDef.upper)) continue;
|
||||
const matched = yakuDef.calc(state, fourMentsuOneJyantou);
|
||||
if (matched) {
|
||||
matchedYakus.push(yakuDef);
|
||||
const waitPatterns = oneHeadFourMentsuPatterns.map(
|
||||
fourMentsuOneJyantou => calcWaitPatterns(fourMentsuOneJyantou, agariTile)
|
||||
).flat();
|
||||
|
||||
const yakumanPatterns = waitPatterns.map(fourMentsuOneJyantouWithWait => {
|
||||
const matchedYakus: Required<YakumanData>[] = [];
|
||||
for (const yakuDef of YAKUMAN_DEFINITIONS) {
|
||||
if (yakuDef.upper && matchedYakus.some(yaku => yaku.name === yakuDef.upper)) continue;
|
||||
const matched = yakuDef.calc(state, fourMentsuOneJyantouWithWait);
|
||||
if (matched) {
|
||||
matchedYakus.push(YAKUMAN_DATA_MAP.get(yakuDef.name)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matchedYakus;
|
||||
}).filter(yakus => yakus.length > 0);
|
||||
return matchedYakus;
|
||||
}).filter(yakus => yakus.length > 0);
|
||||
|
||||
if (yakumanPatterns.length > 0) {
|
||||
return yakumanPatterns[0].map(yaku => yaku.name);
|
||||
return new YakumanSet(yakumanPatterns[0]);
|
||||
}
|
||||
|
||||
const yakuPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => {
|
||||
return NORMAL_YAKU_DEFINITIONS.map(yakuDef => {
|
||||
const result = yakuDef.calc(state, fourMentsuOneJyantou);
|
||||
return result ? yakuDef : null;
|
||||
}).filter(yaku => yaku != null) as YakuDefiniyion[];
|
||||
}).filter(yakus => yakus.length > 0);
|
||||
const yakuPatterns = waitPatterns.map(
|
||||
fourMentsuOneJyantouWithWait => NORMAL_YAKU_DEFINITIONS.filter(
|
||||
yakuDef => yakuDef.calc(state, fourMentsuOneJyantouWithWait)
|
||||
).map(yakuDef => NORMAL_YAKU_DATA_MAP.get(yakuDef.name)!)
|
||||
).filter(yakus => yakus.length > 0);
|
||||
|
||||
const isMenzen = state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type));
|
||||
const isMenzen = state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type));
|
||||
|
||||
if (yakuPatterns.length == 0) {
|
||||
return new NormalYakuSet(isMenzen, []);
|
||||
}
|
||||
|
||||
let maxYakus = yakuPatterns[0];
|
||||
let maxFan = 0;
|
||||
|
@ -814,5 +1057,9 @@ export function calcYakus(state: EnvForCalcYaku): YakuName[] {
|
|||
}
|
||||
}
|
||||
|
||||
return maxYakus.map(yaku => yaku.name);
|
||||
return new NormalYakuSet(isMenzen, maxYakus);
|
||||
}
|
||||
|
||||
export function calcYakus(state: EnvForCalcYaku): YakuName[] {
|
||||
return calcYakusWithDetail(state).yakuNames;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
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