1
0
forked from mirror/misskey
mi.moris.day/packages/misskey-mahjong/test/engine.ts
Take-John bf818a6656
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
2024-08-15 12:29:31 +09:00

236 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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,
});
});
});