236 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			236 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| /*
 | ||
|  * 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,
 | ||
| 		});
 | ||
| 	});
 | ||
| });
 |