misskey/packages/misskey-mahjong/test/engine.ts

236 lines
7.5 KiB
TypeScript
Raw Normal View History

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
2024-08-15 03:29:31 +00:00
/*
* 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,
});
});
});