diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index d7318a43ad..629ef1b01d 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -195,6 +195,10 @@ export interface ReversiGameEventTypes { } export interface MahjongRoomEventTypes { + joined: { + index: number; + user: Packed<'UserLite'>; + }; changeReadyStates: { user1: boolean; user2: boolean; diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts index 692be75ab5..9e962dc22b 100644 --- a/packages/backend/src/core/MahjongService.ts +++ b/packages/backend/src/core/MahjongService.ts @@ -54,7 +54,7 @@ type Room = { isStarted?: boolean; timeLimitForEachTurn: number; - gameState?: Mahjong.Engine.MasterState; + gameState?: Mahjong.MasterState; }; type CallAndRonAnswers = { @@ -262,7 +262,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { throw new Error('Not ready'); } - room.gameState = Mahjong.Engine.MasterGameEngine.createInitialState(); + room.gameState = Mahjong.MasterGameEngine.createInitialState(); room.isStarted = true; await this.saveRoom(room); @@ -281,8 +281,8 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - private async answer(room: Room, engine: Mahjong.Engine.MasterGameEngine, answers: CallAndRonAnswers) { - const res = engine.op_resolveCallAndRonInterruption({ + private async answer(room: Room, engine: Mahjong.MasterGameEngine, answers: CallAndRonAnswers) { + const res = engine.commit_resolveCallAndRonInterruption({ pon: answers.pon ?? false, cii: answers.cii ?? false, kan: answers.kan ?? false, @@ -306,7 +306,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - private async next(room: Room, engine: Mahjong.Engine.MasterGameEngine) { + private async next(room: Room, engine: Mahjong.MasterGameEngine) { const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => engine.getHouse(id)); const userId = engine.state.user1House === engine.state.turn ? room.user1Id : engine.state.user2House === engine.state.turn ? room.user2Id : engine.state.user3House === engine.state.turn ? room.user3Id : room.user4Id; @@ -314,13 +314,12 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { // TODO: ちゃんと思考するようにする setTimeout(() => { const house = engine.state.turn; - const handTiles = house === 'e' ? engine.state.eHandTiles : house === 's' ? engine.state.sHandTiles : house === 'w' ? engine.state.wHandTiles : engine.state.nHandTiles; - this.dahai(room, engine, engine.state.turn, handTiles.at(-1)); + this.dahai(room, engine, engine.state.turn, engine.state.handTiles[house].at(-1)); }, 500); } else { - if (engine.isRiichiHouse(engine.state.turn)) { + if (engine.state.riichis[engine.state.turn]) { // リーチ時はアガリ牌でない限りツモ切り - const handTiles = engine.getHandTilesOf(engine.state.turn); + const handTiles = engine.state.handTiles[engine.state.turn]; const horaSets = Mahjong.Utils.getHoraSets(handTiles); if (horaSets.length === 0) { setTimeout(() => { @@ -336,8 +335,12 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - private async dahai(room: Room, engine: Mahjong.Engine.MasterGameEngine, house: Mahjong.Common.House, tile: Mahjong.Common.Tile, riichi = false) { - const res = engine.op_dahai(house, tile, riichi); + private async endKyoku(room: Room, engine: Mahjong.MasterGameEngine) { + } + + @bindThis + private async dahai(room: Room, engine: Mahjong.MasterGameEngine, house: Mahjong.Common.House, tile: Mahjong.Common.Tile, riichi = false) { + const res = engine.commit_dahai(house, tile, riichi); room.gameState = engine.state; await this.saveRoom(room); @@ -359,13 +362,13 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { }; // リーチ中はポン、チー、カンできない - if (res.canPonHouse != null && engine.isRiichiHouse(res.canPonHouse)) { + if (res.canPonHouse != null && engine.state.riichis[res.canPonHouse]) { answers.pon = false; } - if (res.canCiiHouse != null && engine.isRiichiHouse(res.canCiiHouse)) { + if (res.canCiiHouse != null && engine.state.riichis[res.canCiiHouse]) { answers.cii = false; } - if (res.canKanHouse != null && engine.isRiichiHouse(res.canKanHouse)) { + if (res.canKanHouse != null && engine.state.riichis[res.canKanHouse]) { answers.kan = false; } @@ -423,18 +426,18 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async op_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: string, riichi = false) { + public async commit_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: string, riichi = false) { const room = await this.getRoom(roomId); if (room == null) return; if (room.gameState == null) return; if (!Mahjong.Utils.isTile(tile)) return; - const engine = new Mahjong.Engine.MasterGameEngine(room.gameState); + const engine = new Mahjong.MasterGameEngine(room.gameState); const myHouse = user.id === room.user1Id ? engine.state.user1House : user.id === room.user2Id ? engine.state.user2House : user.id === room.user3Id ? engine.state.user3House : engine.state.user4House; if (riichi) { - if (Mahjong.Utils.getHoraTiles(engine.getHandTilesOf(myHouse)).length === 0) return; - if (engine.getPointsOf(myHouse) < 1000) return; + if (Mahjong.Utils.getHoraTiles(engine.state.handTiles[myHouse]).length === 0) return; + if (engine.state.points[myHouse] < 1000) return; } await this.clearTurnWaitingTimer(room.id); @@ -443,12 +446,12 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async op_ron(roomId: MiMahjongGame['id'], user: MiUser) { + public async commit_ron(roomId: MiMahjongGame['id'], user: MiUser) { const room = await this.getRoom(roomId); if (room == null) return; if (room.gameState == null) return; - const engine = new Mahjong.Engine.MasterGameEngine(room.gameState); + const engine = new Mahjong.MasterGameEngine(room.gameState); const myHouse = user.id === room.user1Id ? engine.state.user1House : user.id === room.user2Id ? engine.state.user2House : user.id === room.user3Id ? engine.state.user3House : engine.state.user4House; // TODO: 自分にロン回答する権利がある状態かバリデーション @@ -462,12 +465,12 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async op_pon(roomId: MiMahjongGame['id'], user: MiUser) { + public async commit_pon(roomId: MiMahjongGame['id'], user: MiUser) { const room = await this.getRoom(roomId); if (room == null) return; if (room.gameState == null) return; - const engine = new Mahjong.Engine.MasterGameEngine(room.gameState); + const engine = new Mahjong.MasterGameEngine(room.gameState); const myHouse = user.id === room.user1Id ? engine.state.user1House : user.id === room.user2Id ? engine.state.user2House : user.id === room.user3Id ? engine.state.user3House : engine.state.user4House; // TODO: 自分にポン回答する権利がある状態かバリデーション @@ -481,12 +484,12 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async op_nop(roomId: MiMahjongGame['id'], user: MiUser) { + public async commit_nop(roomId: MiMahjongGame['id'], user: MiUser) { const room = await this.getRoom(roomId); if (room == null) return; if (room.gameState == null) return; - const engine = new Mahjong.Engine.MasterGameEngine(room.gameState); + const engine = new Mahjong.MasterGameEngine(room.gameState); const myHouse = user.id === room.user1Id ? engine.state.user1House : user.id === room.user2Id ? engine.state.user2House : user.id === room.user3Id ? engine.state.user3House : engine.state.user4House; // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 @@ -509,7 +512,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { * @param engine */ @bindThis - private async waitForTurn(room: Room, userId: MiUser['id'], engine: Mahjong.Engine.MasterGameEngine) { + private async waitForTurn(room: Room, userId: MiUser['id'], engine: Mahjong.MasterGameEngine) { const id = Math.random().toString(36).slice(2); console.log('waitForTurn', userId, id); this.redisClient.sadd(`mahjong:gameTurnWaiting:${room.id}`, id); @@ -525,7 +528,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { console.log('turn timeout', userId, id); clearInterval(interval); const house = room.user1Id === userId ? engine.state.user1House : room.user2Id === userId ? engine.state.user2House : room.user3Id === userId ? engine.state.user3House : engine.state.user4House; - const handTiles = engine.getHandTilesOf(house); + const handTiles = engine.state.handTiles[house]; await this.dahai(room, engine, house, handTiles.at(-1)); return; } diff --git a/packages/backend/src/server/api/stream/channels/mahjong-room.ts b/packages/backend/src/server/api/stream/channels/mahjong-room.ts index 53d3b940c6..e7a0c81abd 100644 --- a/packages/backend/src/server/api/stream/channels/mahjong-room.ts +++ b/packages/backend/src/server/api/stream/channels/mahjong-room.ts @@ -71,28 +71,28 @@ class MahjongRoomChannel extends Channel { private async dahai(tile: string, riichi = false) { if (this.user == null) return; - this.mahjongService.op_dahai(this.roomId!, this.user, tile, riichi); + this.mahjongService.commit_dahai(this.roomId!, this.user, tile, riichi); } @bindThis private async ron() { if (this.user == null) return; - this.mahjongService.op_ron(this.roomId!, this.user); + this.mahjongService.commit_ron(this.roomId!, this.user); } @bindThis private async pon() { if (this.user == null) return; - this.mahjongService.op_pon(this.roomId!, this.user); + this.mahjongService.commit_pon(this.roomId!, this.user); } @bindThis private async nop() { if (this.user == null) return; - this.mahjongService.op_nop(this.roomId!, this.user); + this.mahjongService.commit_nop(this.roomId!, this.user); } @bindThis diff --git a/packages/frontend/assets/mahjong/ron.png b/packages/frontend/assets/mahjong/ron.png new file mode 100644 index 0000000000..8bd40d6dca Binary files /dev/null and b/packages/frontend/assets/mahjong/ron.png differ diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue index fcccb8b319..9105992e2d 100644 --- a/packages/frontend/src/pages/mahjong/room.game.vue +++ b/packages/frontend/src/pages/mahjong/room.game.vue @@ -7,19 +7,19 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
-
+
-
+
@@ -27,28 +27,28 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
-
+
-
+
-
+
@@ -67,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -111,7 +111,7 @@ const props = defineProps<{ const room = ref(deepClone(props.room)); const myUserNumber = computed(() => room.value.user1Id === $i.id ? 1 : room.value.user2Id === $i.id ? 2 : room.value.user3Id === $i.id ? 3 : 4); -const engine = shallowRef(new Mahjong.Engine.PlayerGameEngine(myUserNumber.value, room.value.gameState)); +const engine = shallowRef(new Mahjong.PlayerGameEngine(myUserNumber.value, room.value.gameState)); const isMyTurn = computed(() => { return engine.value.state.turn === engine.value.myHouse; @@ -205,7 +205,7 @@ if (!props.room.isEnded) { function dahai(tile: Mahjong.Common.Tile, ev: MouseEvent) { if (!isMyTurn.value) return; - engine.value.op_dahai(engine.value.myHouse, tile); + engine.value.commit_dahai(engine.value.myHouse, tile); iTsumoed.value = false; triggerRef(engine); @@ -217,7 +217,7 @@ function dahai(tile: Mahjong.Common.Tile, ev: MouseEvent) { function riichi() { if (!isMyTurn.value) return; - engine.value.op_dahai(engine.value.myHouse, tile, true); + engine.value.commit_dahai(engine.value.myHouse, tile, true); iTsumoed.value = false; triggerRef(engine); @@ -228,7 +228,7 @@ function riichi() { } function ron() { - engine.value.op_ron(engine.value.state.canRonSource, engine.value.myHouse); + engine.value.commit_ron(engine.value.state.canRonSource, engine.value.myHouse); triggerRef(engine); props.connection!.send('ron', { @@ -236,7 +236,7 @@ function ron() { } function pon() { - engine.value.op_pon(engine.value.state.canPonSource, engine.value.myHouse); + engine.value.commit_pon(engine.value.state.canPonSource, engine.value.myHouse); triggerRef(engine); props.connection!.send('pon', { @@ -244,7 +244,7 @@ function pon() { } function skip() { - engine.value.op_nop(engine.value.myHouse); + engine.value.commit_nop(engine.value.myHouse); triggerRef(engine); props.connection!.send('nop', {}); @@ -270,7 +270,7 @@ function onStreamDahai(log) { // return; //} - engine.value.op_dahai(log.house, log.tile); + engine.value.commit_dahai(log.house, log.tile); triggerRef(engine); myTurnTimerRmain.value = room.value.timeLimitForEachTurn; @@ -287,7 +287,7 @@ function onStreamTsumo(log) { // return; //} - engine.value.op_tsumo(log.house, log.tile); + engine.value.commit_tsumo(log.house, log.tile); triggerRef(engine); if (log.house === engine.value.myHouse) { @@ -309,12 +309,12 @@ function onStreamDahaiAndTsumo(log) { //} if (log.dahaiHouse !== engine.value.myHouse) { - engine.value.op_dahai(log.dahaiHouse, log.dahaiTile); + engine.value.commit_dahai(log.dahaiHouse, log.dahaiTile); triggerRef(engine); } window.setTimeout(() => { - engine.value.op_tsumo(Mahjong.Utils.nextHouse(log.dahaiHouse), log.tsumoTile); + engine.value.commit_tsumo(Mahjong.Utils.nextHouse(log.dahaiHouse), log.tsumoTile); triggerRef(engine); if (Mahjong.Utils.nextHouse(log.dahaiHouse) === engine.value.myHouse) { @@ -338,7 +338,7 @@ function onStreamPonned(log) { if (log.target === engine.value.myHouse) return; - engine.value.op_pon(log.source, log.target); + engine.value.commit_pon(log.source, log.target); triggerRef(engine); myTurnTimerRmain.value = room.value.timeLimitForEachTurn; @@ -347,7 +347,7 @@ function onStreamPonned(log) { function restoreRoom(_room) { room.value = deepClone(_room); - engine.value = new Mahjong.Engine.PlayerGameEngine(myUserNumber, room.value.gameState); + engine.value = new Mahjong.PlayerGameEngine(myUserNumber, room.value.gameState); } onMounted(() => { diff --git a/packages/misskey-mahjong/src/common.ts b/packages/misskey-mahjong/src/common.ts index bf7d15491e..0e3e0243bc 100644 --- a/packages/misskey-mahjong/src/common.ts +++ b/packages/misskey-mahjong/src/common.ts @@ -46,3 +46,190 @@ export const TILE_TYPES = [ export type Tile = typeof TILE_TYPES[number]; export type House = 'e' | 's' | 'w' | 'n'; + +export type Huro = { + type: 'pon'; + tile: Tile; + from: House; +} | { + type: 'cii'; + tiles: [Tile, Tile, Tile]; + from: House; +} | { + type: 'minkan'; + tile: Tile; + from: House; +} | { + type: 'ankan'; + tile: Tile; + from: House; +}; + +export const yakuNames = [ + 'riichi', + 'ippatsu', + 'tsumo', + 'tanyao', + 'pinfu', + 'iipeko', + 'field-wind', + 'seat-wind', + 'white', + 'green', + 'red', + 'rinshan', + 'chankan', + 'haitei', + 'hotei', + 'sanshoku-dojun', + 'sanshoku-doko', + 'ittsu', + 'chanta', + 'chitoitsu', + 'toitoi', + 'sananko', + 'honroto', + 'sankantsu', + 'shosangen', + 'double-riichi', + 'honitsu', + 'junchan', + 'ryampeko', + 'chinitsu', + 'dora', + 'red-dora', +] as const; + +export const yakumanNames = [ + 'kokushi', + 'kokushi-13', + 'suanko', + 'suanko-tanki', + 'daisangen', + 'tsuiso', + 'shosushi', + 'daisushi', + 'ryuiso', + 'chinroto', + 'sukantsu', + 'churen', + 'pure-churen', + 'tenho', + 'chiho', +] as const; + +type EnvForCalcYaku = { + house: House; + + /** + * 和了る人の手牌(副露した牌は含まない) + */ + handTiles: Tile[]; + + /** + * 河 + */ + hoTiles: Tile[]; + + /** + * 副露 + */ + huros: Huro[]; + + /** + * ツモ牌 + */ + tsumoTile: Tile | null; + + /** + * ロン牌 + */ + ronTile: Tile | null; + + /** + * ドラ表示牌 + */ + doraTiles: Tile[]; + + /** + * 赤ドラ表示牌 + */ + redDoraTiles: Tile[]; + + /** + * 場風 + */ + fieldWind: House; + + /** + * 自風 + */ + seatWind: House; + + /** + * リーチしたかどうか + */ + riichi: boolean; +}; + +const YAKU_DEFINITIONS = [{ + name: 'riichi', + fan: 1, + calc: (state: EnvForCalcYaku) => { + return state.riichi; + }, +}, { + name: 'red', + fan: 1, + calc: (state: EnvForCalcYaku) => { + return ( + (state.handTiles.filter(t => t === 'chun').length >= 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) + ); + }, +}, { + name: 'white', + fan: 1, + calc: (state: EnvForCalcYaku) => { + return ( + (state.handTiles.filter(t => t === 'haku').length >= 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) + ); + }, +}, { + name: 'green', + fan: 1, + calc: (state: EnvForCalcYaku) => { + return ( + (state.handTiles.filter(t => t === 'hatsu').length >= 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: 'tanyao', + fan: 1, + calc: (state: EnvForCalcYaku) => { + const yaochuTiles: Tile[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; + return ( + (state.handTiles.filter(t => yaochuTiles.includes(t)).length === 0) && + (state.huros.filter(huro => + huro.type === 'pon' ? yaochuTiles.includes(huro.tile) : + huro.type === 'ankan' ? yaochuTiles.includes(huro.tile) : + huro.type === 'minkan' ? yaochuTiles.includes(huro.tile) : + huro.type === 'cii' ? huro.tiles.some(t2 => yaochuTiles.includes(t2)) : + false).length === 0) + ); + }, +}]; diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts new file mode 100644 index 0000000000..b581ba9191 --- /dev/null +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -0,0 +1,455 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import CRC32 from 'crc-32'; +import { Tile, House, Huro, TILE_TYPES } from './common.js'; +import * as Utils from './utils.js'; +import { PlayerState } from './engine.player.js'; + +export type MasterState = { + user1House: House; + user2House: House; + user3House: House; + user4House: House; + tiles: Tile[]; + + /** + * 副露した牌を含まない手牌 + */ + handTiles: { + e: Tile[]; + s: Tile[]; + w: Tile[]; + n: Tile[]; + }; + + hoTiles: { + e: Tile[]; + s: Tile[]; + w: Tile[]; + n: Tile[]; + }; + huros: { + e: Huro[]; + s: Huro[]; + w: Huro[]; + n: Huro[]; + }; + riichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + points: { + e: number; + s: number; + w: number; + n: number; + }; + turn: House | null; + nextTurnAfterAsking: House | null; + + ronAsking: { + /** + * 牌を捨てた人 + */ + source: House; + + /** + * ロンする権利がある人 + */ + targets: House[]; + } | null; + + ponAsking: { + /** + * 牌を捨てた人 + */ + source: House; + + /** + * ポンする権利がある人 + */ + target: House; + } | null; + + ciiAsking: { + /** + * 牌を捨てた人 + */ + source: House; + + /** + * チーする権利がある人(sourceの下家なのは自明だがプログラム簡略化のため) + */ + target: House; + } | null; + + kanAsking: { + /** + * 牌を捨てた人 + */ + source: House; + + /** + * カンする権利がある人 + */ + target: House; + } | null; +}; + +export class MasterGameEngine { + public state: MasterState; + + constructor(state: MasterState) { + this.state = state; + } + + public static createInitialState(): MasterState { + const tiles = [...TILE_TYPES.slice(), ...TILE_TYPES.slice(), ...TILE_TYPES.slice(), ...TILE_TYPES.slice()]; + tiles.sort(() => Math.random() - 0.5); + + const eHandTiles = tiles.splice(0, 14); + const sHandTiles = tiles.splice(0, 13); + const wHandTiles = tiles.splice(0, 13); + const nHandTiles = tiles.splice(0, 13); + + return { + user1House: 'e', + user2House: 's', + user3House: 'w', + user4House: 'n', + tiles, + handTiles: { + e: eHandTiles, + s: sHandTiles, + w: wHandTiles, + n: nHandTiles, + }, + hoTiles: { + e: [], + s: [], + w: [], + n: [], + }, + huros: { + e: [], + s: [], + w: [], + n: [], + }, + riichis: { + e: false, + s: false, + w: false, + n: false, + }, + points: { + e: 25000, + s: 25000, + w: 25000, + n: 25000, + }, + turn: 'e', + nextTurnAfterAsking: null, + ponAsking: null, + ciiAsking: null, + kanAsking: null, + ronAsking: null, + }; + } + + private tsumo(): Tile { + const tile = this.state.tiles.pop(); + 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); + return tile; + } + + private canRon(house: House, tile: Tile): boolean { + // フリテン + // TODO: ポンされるなどして自分の河にない場合の考慮 + if (this.state.hoTiles[house].includes(tile)) return false; + + const horaSets = Utils.getHoraSets(this.state.handTiles[house].concat(tile)); + if (horaSets.length === 0) return false; // 完成形じゃない + + // TODO + //const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc(this.state, { tsumoTile: null, ronTile: tile })); + //if (yakus.length === 0) return false; // 役がない + + return true; + } + + private canPon(house: House, tile: Tile): boolean { + return this.state.handTiles[house].filter(t => t === tile).length === 2; + } + + public getHouse(index: 1 | 2 | 3 | 4): House { + switch (index) { + case 1: return this.state.user1House; + case 2: return this.state.user2House; + case 3: return this.state.user3House; + case 4: return this.state.user4House; + } + } + + public commit_dahai(house: House, tile: Tile, riichi = false) { + if (this.state.turn !== house) throw new Error('Not your turn'); + + const handTiles = this.state.handTiles[house]; + if (!handTiles.includes(tile)) throw new Error('No such tile in your hand'); + handTiles.splice(handTiles.indexOf(tile), 1); + this.state.hoTiles[house].push(tile); + + if (riichi) { + this.state.riichis[house] = true; + } + + const canRonHouses: House[] = []; + switch (house) { + case 'e': + if (this.canRon('s', tile)) canRonHouses.push('s'); + if (this.canRon('w', tile)) canRonHouses.push('w'); + if (this.canRon('n', tile)) canRonHouses.push('n'); + break; + case 's': + if (this.canRon('e', tile)) canRonHouses.push('e'); + if (this.canRon('w', tile)) canRonHouses.push('w'); + if (this.canRon('n', tile)) canRonHouses.push('n'); + break; + case 'w': + if (this.canRon('e', tile)) canRonHouses.push('e'); + if (this.canRon('s', tile)) canRonHouses.push('s'); + if (this.canRon('n', tile)) canRonHouses.push('n'); + break; + case 'n': + if (this.canRon('e', tile)) canRonHouses.push('e'); + if (this.canRon('s', tile)) canRonHouses.push('s'); + if (this.canRon('w', tile)) canRonHouses.push('w'); + break; + } + + const canKanHouse: House | null = null; + + let canPonHouse: House | null = null; + switch (house) { + case 'e': + canPonHouse = this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null; + break; + case 's': + canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null; + break; + case 'w': + canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('n', tile) ? 'n' : null; + break; + case 'n': + canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : null; + break; + } + + const canCiiHouse: House | null = null; + // TODO + //let canCii: boolean = false; + //if (house === 'e') { + // canCii = this.state.sHandTiles... + //} else if (house === 's') { + // canCii = this.state.wHandTiles... + //} else if (house === 'w') { + // canCii = this.state.nHandTiles... + //} else if (house === 'n') { + // canCii = this.state.eHandTiles... + //} + + if (canRonHouses.length > 0 || canPonHouse != null) { + if (canRonHouses.length > 0) { + this.state.ronAsking = { + source: house, + targets: canRonHouses, + }; + } + if (canKanHouse != null) { + this.state.kanAsking = { + source: house, + target: canKanHouse, + }; + } + if (canPonHouse != null) { + this.state.ponAsking = { + source: house, + target: canPonHouse, + }; + } + if (canCiiHouse != null) { + this.state.ciiAsking = { + source: house, + target: canCiiHouse, + }; + } + this.state.turn = null; + this.state.nextTurnAfterAsking = Utils.nextHouse(house); + return { + asking: true, + canRonHouses: canRonHouses, + canKanHouse: canKanHouse, + canPonHouse: canPonHouse, + canCiiHouse: canCiiHouse, + }; + } + + this.state.turn = Utils.nextHouse(house); + + const tsumoTile = this.tsumo(); + + return { + asking: false, + tsumoTile: tsumoTile, + }; + } + + public commit_resolveCallAndRonInterruption(answers: { + pon: boolean; + cii: boolean; + kan: boolean; + ron: House[]; + }) { + if (this.state.ponAsking == null && this.state.ciiAsking == null && this.state.kanAsking == null && this.state.ronAsking == null) throw new Error(); + + const clearAsking = () => { + this.state.ponAsking = null; + this.state.ciiAsking = null; + this.state.kanAsking = null; + this.state.ronAsking = null; + }; + + if (this.state.ronAsking != null && answers.ron.length > 0) { + // TODO + return; + } + + if (this.state.kanAsking != null && answers.kan) { + const source = this.state.kanAsking.source; + const target = this.state.kanAsking.target; + + const tile = this.state.hoTiles[source].pop()!; + this.state.huros[target].push({ type: 'minkan', tile, from: source }); + + clearAsking(); + this.state.turn = target; + // TODO + return; + } + + if (this.state.ponAsking != null && answers.pon) { + const source = this.state.ponAsking.source; + const target = this.state.ponAsking.target; + + const tile = this.state.hoTiles[source].pop()!; + this.state.handTiles[target].splice(this.state.handTiles[target].indexOf(tile), 1); + this.state.handTiles[target].splice(this.state.handTiles[target].indexOf(tile), 1); + this.state.huros[target].push({ type: 'pon', tile, from: source }); + + clearAsking(); + this.state.turn = target; + return { + type: 'ponned', + source, + target, + tile, + }; + } + + if (this.state.ciiAsking != null && answers.cii) { + const source = this.state.ciiAsking.source; + const target = this.state.ciiAsking.target; + + const tile = this.state.hoTiles[source].pop()!; + this.state.huros[target].push({ type: 'cii', tile, from: source }); + + clearAsking(); + this.state.turn = target; + return { + type: 'ciied', + source, + target, + tile, + }; + } + + clearAsking(); + this.state.turn = this.state.nextTurnAfterAsking; + this.state.nextTurnAfterAsking = null; + + const tile = this.tsumo(); + + return { + type: 'tsumo', + house: this.state.turn, + tile, + }; + } + + public createPlayerState(index: 1 | 2 | 3 | 4): PlayerState { + const house = this.getHouse(index); + + return { + user1House: this.state.user1House, + user2House: this.state.user2House, + user3House: this.state.user3House, + user4House: this.state.user4House, + tilesCount: this.state.tiles.length, + handTiles: { + e: house === 'e' ? this.state.handTiles.e : this.state.handTiles.e.map(() => null), + s: house === 's' ? this.state.handTiles.s : this.state.handTiles.s.map(() => null), + w: house === 'w' ? this.state.handTiles.w : this.state.handTiles.w.map(() => null), + n: house === 'n' ? this.state.handTiles.n : this.state.handTiles.n.map(() => null), + }, + hoTiles: { + e: this.state.hoTiles.e, + s: this.state.hoTiles.s, + w: this.state.hoTiles.w, + n: this.state.hoTiles.n, + }, + huros: { + e: this.state.huros.e, + s: this.state.huros.s, + w: this.state.huros.w, + n: this.state.huros.n, + }, + riichis: { + e: this.state.riichis.e, + s: this.state.riichis.s, + w: this.state.riichis.w, + n: this.state.riichis.n, + }, + points: { + e: this.state.points.e, + s: this.state.points.s, + w: this.state.points.w, + n: this.state.points.n, + }, + latestDahaiedTile: null, + turn: this.state.turn, + }; + } + + public calcCrc32ForUser1(): number { + // TODO + } + + public calcCrc32ForUser2(): number { + // TODO + } + + public calcCrc32ForUser3(): number { + // TODO + } + + public calcCrc32ForUser4(): number { + // TODO + } +} diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts new file mode 100644 index 0000000000..f1c9a6cb3a --- /dev/null +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import CRC32 from 'crc-32'; +import { Tile, House, Huro, TILE_TYPES } from './common.js'; +import * as Utils from './utils.js'; + +export type PlayerState = { + user1House: House; + user2House: House; + user3House: House; + user4House: House; + tilesCount: number; + + /** + * 副露した牌を含まない手牌 + */ + handTiles: { + e: Tile[] | null[]; + s: Tile[] | null[]; + w: Tile[] | null[]; + n: Tile[] | null[]; + }; + + hoTiles: { + e: Tile[]; + s: Tile[]; + w: Tile[]; + n: Tile[]; + }; + huros: { + e: Huro[]; + s: Huro[]; + w: Huro[]; + n: Huro[]; + }; + riichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + points: { + e: number; + s: number; + w: number; + n: number; + }; + latestDahaiedTile: Tile | null; + turn: House | null; + canPonSource: House | null; + canCiiSource: House | null; + canKanSource: House | null; + canRonSource: House | null; + canCiiTo: House | null; + canKanTo: House | null; + canRonTo: House | null; +}; + +export class PlayerGameEngine { + /** + * このエラーが発生したときはdesyncが疑われる + */ + public static InvalidOperationError = class extends Error {}; + + private myUserNumber: 1 | 2 | 3 | 4; + public state: PlayerState; + + constructor(myUserNumber: PlayerGameEngine['myUserNumber'], state: PlayerState) { + this.myUserNumber = myUserNumber; + this.state = state; + } + + public get myHouse(): House { + switch (this.myUserNumber) { + case 1: return this.state.user1House; + case 2: return this.state.user2House; + case 3: return this.state.user3House; + case 4: return this.state.user4House; + } + } + + public get myHandTiles(): Tile[] { + return this.state.handTiles[this.myHouse] as Tile[]; + } + + public get isMeRiichi(): boolean { + return this.state.riichis[this.myHouse]; + } + + public commit_tsumo(house: House, tile: Tile) { + console.log('commit_tsumo', this.state.turn, house, tile); + this.state.turn = house; + if (house === this.myHouse) { + this.myHandTiles.push(tile); + } else { + this.state.handTiles[house].push(null); + } + } + + public commit_dahai(house: House, tile: Tile, riichi = false) { + console.log('commit_dahai', this.state.turn, house, tile, riichi); + if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError(); + + if (riichi) { + this.state.riichis[house] = true; + } + + if (house === this.myHouse) { + this.myHandTiles.splice(this.myHandTiles.indexOf(tile), 1); + this.state.hoTiles[this.myHouse].push(tile); + } else { + this.state.handTiles[house].pop(); + this.state.hoTiles[house].push(tile); + } + + this.state.turn = null; + + if (house === this.myHouse) { + } else { + const canRon = Utils.getHoraSets(this.myHandTiles.concat(tile)).length > 0; + const canPon = this.myHandTiles.filter(t => t === tile).length === 2; + + // TODO: canCii + + if (canRon) this.state.canRonSource = house; + if (canPon) this.state.canPonSource = house; + } + } + + /** + * ロンします + * @param source 牌を捨てた人 + * @param target ロンした人 + */ + public commit_ron(source: House, target: House) { + this.state.canRonSource = null; + + const lastTile = this.state.hoTiles[source].pop(); + if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError(); + if (target === this.myHouse) { + this.myHandTiles.push(lastTile); + } else { + this.state.handTiles[target].push(null); + } + this.state.turn = null; + } + + /** + * ポンします + * @param source 牌を捨てた人 + * @param target ポンした人 + */ + public commit_pon(source: House, target: House) { + this.state.canPonSource = null; + + const lastTile = this.state.hoTiles[source].pop(); + if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError(); + if (target === this.myHouse) { + this.myHandTiles.splice(this.myHandTiles.indexOf(lastTile), 1); + this.myHandTiles.splice(this.myHandTiles.indexOf(lastTile), 1); + } else { + this.state.handTiles[target].unshift(); + this.state.handTiles[target].unshift(); + } + this.state.huros[target].push({ type: 'pon', tile: lastTile, from: source }); + + this.state.turn = target; + } + + public commit_nop() { + this.state.canRonSource = null; + this.state.canPonSource = null; + } +} diff --git a/packages/misskey-mahjong/src/engine.ts b/packages/misskey-mahjong/src/engine.ts deleted file mode 100644 index 27371b86d3..0000000000 --- a/packages/misskey-mahjong/src/engine.ts +++ /dev/null @@ -1,723 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import CRC32 from 'crc-32'; -import { Tile, House, TILE_TYPES } from './common.js'; -import * as Utils from './utils.js'; - -type Huro = { - type: 'pon'; - tile: Tile; - from: House; -} | { - type: 'cii'; - tiles: [Tile, Tile, Tile]; - from: House; -} | { - type: 'kan'; - tile: Tile; - from: House; -} | { - type: 'kakan'; - tile: Tile; - from: House; -} | { - type: 'ankan'; - tile: Tile; - from: House; -}; - -export type MasterState = { - user1House: House; - user2House: House; - user3House: House; - user4House: House; - tiles: Tile[]; - eHandTiles: Tile[]; - sHandTiles: Tile[]; - wHandTiles: Tile[]; - nHandTiles: Tile[]; - eHoTiles: Tile[]; - sHoTiles: Tile[]; - wHoTiles: Tile[]; - nHoTiles: Tile[]; - eHuros: Huro[]; - sHuros: Huro[]; - wHuros: Huro[]; - nHuros: Huro[]; - eRiichi: boolean; - sRiichi: boolean; - wRiichi: boolean; - nRiichi: boolean; - ePoints: number; - sPoints: number; - wPoints: number; - nPoints: number; - turn: House | null; - nextTurnAfterAsking: House | null; - - ronAsking: { - /** - * 牌を捨てた人 - */ - source: House; - - /** - * ロンする権利がある人 - */ - targets: House[]; - } | null; - - ponAsking: { - /** - * 牌を捨てた人 - */ - source: House; - - /** - * ポンする権利がある人 - */ - target: House; - } | null; - - ciiAsking: { - /** - * 牌を捨てた人 - */ - source: House; - - /** - * チーする権利がある人(sourceの下家なのは自明だがプログラム簡略化のため) - */ - target: House; - } | null; - - kanAsking: { - /** - * 牌を捨てた人 - */ - source: House; - - /** - * カンする権利がある人 - */ - target: House; - } | null; -}; - -export class MasterGameEngine { - public state: MasterState; - - constructor(state: MasterState) { - this.state = state; - } - - public static createInitialState(): MasterState { - const tiles = [...TILE_TYPES.slice(), ...TILE_TYPES.slice(), ...TILE_TYPES.slice(), ...TILE_TYPES.slice()]; - tiles.sort(() => Math.random() - 0.5); - - const eHandTiles = tiles.splice(0, 14); - const sHandTiles = tiles.splice(0, 13); - const wHandTiles = tiles.splice(0, 13); - const nHandTiles = tiles.splice(0, 13); - - return { - user1House: 'e', - user2House: 's', - user3House: 'w', - user4House: 'n', - tiles, - eHandTiles, - sHandTiles, - wHandTiles, - nHandTiles, - eHoTiles: [], - sHoTiles: [], - wHoTiles: [], - nHoTiles: [], - eHuros: [], - sHuros: [], - wHuros: [], - nHuros: [], - eRiichi: false, - sRiichi: false, - wRiichi: false, - nRiichi: false, - ePoints: 25000, - sPoints: 25000, - wPoints: 25000, - nPoints: 25000, - turn: 'e', - nextTurnAfterAsking: null, - ponAsking: null, - ciiAsking: null, - kanAsking: null, - ronAsking: null, - }; - } - - private tsumo(): Tile { - const tile = this.state.tiles.pop(); - if (tile == null) throw new Error('No tiles left'); - if (this.state.turn == null) throw new Error('Not your turn'); - this.getHandTilesOf(this.state.turn).push(tile); - return tile; - } - - private canRon(house: House, tile: Tile): boolean { - // フリテン - // TODO: ポンされるなどして自分の河にない場合の考慮 - if (this.getHoTilesOf(house).includes(tile)) return false; - - const horaSets = Utils.getHoraSets(this.getHandTilesOf(house).concat(tile)); - if (horaSets.length === 0) return false; // 完成形じゃない - - // TODO - //const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc(this.state, { tsumoTile: null, ronTile: tile })); - //if (yakus.length === 0) return false; // 役がない - - return true; - } - - private canPon(house: House, tile: Tile): boolean { - return this.getHandTilesOf(house).filter(t => t === tile).length === 2; - } - - public getHouse(index: 1 | 2 | 3 | 4): House { - switch (index) { - case 1: return this.state.user1House; - case 2: return this.state.user2House; - case 3: return this.state.user3House; - case 4: return this.state.user4House; - } - } - - public getHandTilesOf(house: House): Tile[] { - switch (house) { - case 'e': return this.state.eHandTiles; - case 's': return this.state.sHandTiles; - case 'w': return this.state.wHandTiles; - case 'n': return this.state.nHandTiles; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public getHoTilesOf(house: House): Tile[] { - switch (house) { - case 'e': return this.state.eHoTiles; - case 's': return this.state.sHoTiles; - case 'w': return this.state.wHoTiles; - case 'n': return this.state.nHoTiles; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public getHurosOf(house: House): Huro[] { - switch (house) { - case 'e': return this.state.eHuros; - case 's': return this.state.sHuros; - case 'w': return this.state.wHuros; - case 'n': return this.state.nHuros; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public getPointsOf(house: House): number { - switch (house) { - case 'e': return this.state.ePoints; - case 's': return this.state.sPoints; - case 'w': return this.state.wPoints; - case 'n': return this.state.nPoints; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public setPointsOf(house: House, points: number) { - switch (house) { - case 'e': this.state.ePoints = points; break; - case 's': this.state.sPoints = points; break; - case 'w': this.state.wPoints = points; break; - case 'n': this.state.nPoints = points; break; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public isRiichiHouse(house: House): boolean { - switch (house) { - case 'e': return this.state.eRiichi; - case 's': return this.state.sRiichi; - case 'w': return this.state.wRiichi; - case 'n': return this.state.nRiichi; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public op_dahai(house: House, tile: Tile, riichi = false) { - if (this.state.turn !== house) throw new Error('Not your turn'); - - const handTiles = this.getHandTilesOf(house); - if (!handTiles.includes(tile)) throw new Error('No such tile in your hand'); - handTiles.splice(handTiles.indexOf(tile), 1); - this.getHoTilesOf(house).push(tile); - - if (riichi) { - switch (house) { - case 'e': this.state.eRiichi = true; break; - case 's': this.state.sRiichi = true; break; - case 'w': this.state.wRiichi = true; break; - case 'n': this.state.nRiichi = true; break; - } - } - - const canRonHouses: House[] = []; - switch (house) { - case 'e': - if (this.canRon('s', tile)) canRonHouses.push('s'); - if (this.canRon('w', tile)) canRonHouses.push('w'); - if (this.canRon('n', tile)) canRonHouses.push('n'); - break; - case 's': - if (this.canRon('e', tile)) canRonHouses.push('e'); - if (this.canRon('w', tile)) canRonHouses.push('w'); - if (this.canRon('n', tile)) canRonHouses.push('n'); - break; - case 'w': - if (this.canRon('e', tile)) canRonHouses.push('e'); - if (this.canRon('s', tile)) canRonHouses.push('s'); - if (this.canRon('n', tile)) canRonHouses.push('n'); - break; - case 'n': - if (this.canRon('e', tile)) canRonHouses.push('e'); - if (this.canRon('s', tile)) canRonHouses.push('s'); - if (this.canRon('w', tile)) canRonHouses.push('w'); - break; - } - - const canKanHouse: House | null = null; - - let canPonHouse: House | null = null; - switch (house) { - case 'e': - canPonHouse = this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null; - break; - case 's': - canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('w', tile) ? 'w' : this.canPon('n', tile) ? 'n' : null; - break; - case 'w': - canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('n', tile) ? 'n' : null; - break; - case 'n': - canPonHouse = this.canPon('e', tile) ? 'e' : this.canPon('s', tile) ? 's' : this.canPon('w', tile) ? 'w' : null; - break; - } - - const canCiiHouse: House | null = null; - // TODO - //let canCii: boolean = false; - //if (house === 'e') { - // canCii = this.state.sHandTiles... - //} else if (house === 's') { - // canCii = this.state.wHandTiles... - //} else if (house === 'w') { - // canCii = this.state.nHandTiles... - //} else if (house === 'n') { - // canCii = this.state.eHandTiles... - //} - - if (canRonHouses.length > 0 || canPonHouse != null) { - if (canRonHouses.length > 0) { - this.state.ronAsking = { - source: house, - targets: canRonHouses, - }; - } - if (canKanHouse != null) { - this.state.kanAsking = { - source: house, - target: canKanHouse, - }; - } - if (canPonHouse != null) { - this.state.ponAsking = { - source: house, - target: canPonHouse, - }; - } - if (canCiiHouse != null) { - this.state.ciiAsking = { - source: house, - target: canCiiHouse, - }; - } - this.state.turn = null; - this.state.nextTurnAfterAsking = Utils.nextHouse(house); - return { - asking: true, - canRonHouses: canRonHouses, - canKanHouse: canKanHouse, - canPonHouse: canPonHouse, - canCiiHouse: canCiiHouse, - }; - } - - this.state.turn = Utils.nextHouse(house); - - const tsumoTile = this.tsumo(); - - return { - asking: false, - tsumoTile: tsumoTile, - }; - } - - public op_resolveCallAndRonInterruption(answers: { - pon: boolean; - cii: boolean; - kan: boolean; - ron: House[]; - }) { - if (this.state.ponAsking == null && this.state.ciiAsking == null && this.state.kanAsking == null && this.state.ronAsking == null) throw new Error(); - - const clearAsking = () => { - this.state.ponAsking = null; - this.state.ciiAsking = null; - this.state.kanAsking = null; - this.state.ronAsking = null; - }; - - if (this.state.ronAsking != null && answers.ron.length > 0) { - // TODO - return; - } - - if (this.state.kanAsking != null && answers.kan) { - const source = this.state.kanAsking.source; - const target = this.state.kanAsking.target; - - const tile = this.getHoTilesOf(source).pop()!; - this.getHurosOf(target).push({ type: 'kan', tile, from: source }); - - clearAsking(); - this.state.turn = target; - // TODO - return; - } - - if (this.state.ponAsking != null && answers.pon) { - const source = this.state.ponAsking.source; - const target = this.state.ponAsking.target; - - const tile = this.getHoTilesOf(source).pop()!; - this.getHandTilesOf(target).splice(this.getHandTilesOf(target).indexOf(tile), 1); - this.getHandTilesOf(target).splice(this.getHandTilesOf(target).indexOf(tile), 1); - this.getHurosOf(target).push({ type: 'pon', tile, from: source }); - - clearAsking(); - this.state.turn = target; - return { - type: 'ponned', - source, - target, - tile, - }; - } - - if (this.state.ciiAsking != null && answers.cii) { - const source = this.state.ciiAsking.source; - const target = this.state.ciiAsking.target; - - const tile = this.getHoTilesOf(source).pop()!; - this.getHurosOf(target).push({ type: 'cii', tile, from: source }); - - clearAsking(); - this.state.turn = target; - return { - type: 'ciied', - source, - target, - tile, - }; - } - - clearAsking(); - this.state.turn = this.state.nextTurnAfterAsking; - this.state.nextTurnAfterAsking = null; - - const tile = this.tsumo(); - - return { - type: 'tsumo', - house: this.state.turn, - tile, - }; - } - - public createPlayerState(index: 1 | 2 | 3 | 4): PlayerState { - const house = this.getHouse(index); - - return { - user1House: this.state.user1House, - user2House: this.state.user2House, - user3House: this.state.user3House, - user4House: this.state.user4House, - tilesCount: this.state.tiles.length, - eHandTiles: house === 'e' ? this.state.eHandTiles : this.state.eHandTiles.map(() => null), - sHandTiles: house === 's' ? this.state.sHandTiles : this.state.sHandTiles.map(() => null), - wHandTiles: house === 'w' ? this.state.wHandTiles : this.state.wHandTiles.map(() => null), - nHandTiles: house === 'n' ? this.state.nHandTiles : this.state.nHandTiles.map(() => null), - eHoTiles: this.state.eHoTiles, - sHoTiles: this.state.sHoTiles, - wHoTiles: this.state.wHoTiles, - nHoTiles: this.state.nHoTiles, - eHuros: this.state.eHuros, - sHuros: this.state.sHuros, - wHuros: this.state.wHuros, - nHuros: this.state.nHuros, - eRiichi: this.state.eRiichi, - sRiichi: this.state.sRiichi, - wRiichi: this.state.wRiichi, - nRiichi: this.state.nRiichi, - ePoints: this.state.ePoints, - sPoints: this.state.sPoints, - wPoints: this.state.wPoints, - nPoints: this.state.nPoints, - latestDahaiedTile: null, - turn: this.state.turn, - }; - } - - public calcCrc32ForUser1(): number { - // TODO - } - - public calcCrc32ForUser2(): number { - // TODO - } - - public calcCrc32ForUser3(): number { - // TODO - } - - public calcCrc32ForUser4(): number { - // TODO - } -} - -export type PlayerState = { - user1House: House; - user2House: House; - user3House: House; - user4House: House; - tilesCount: number; - eHandTiles: Tile[] | null[]; - sHandTiles: Tile[] | null[]; - wHandTiles: Tile[] | null[]; - nHandTiles: Tile[] | null[]; - eHoTiles: Tile[]; - sHoTiles: Tile[]; - wHoTiles: Tile[]; - nHoTiles: Tile[]; - eHuros: Huro[]; - sHuros: Huro[]; - wHuros: Huro[]; - nHuros: Huro[]; - eRiichi: boolean; - sRiichi: boolean; - wRiichi: boolean; - nRiichi: boolean; - ePoints: number; - sPoints: number; - wPoints: number; - nPoints: number; - latestDahaiedTile: Tile | null; - turn: House | null; - canPonSource: House | null; - canCiiSource: House | null; - canKanSource: House | null; - canRonSource: House | null; - canCiiTo: House | null; - canKanTo: House | null; - canRonTo: House | null; -}; - -export class PlayerGameEngine { - /** - * このエラーが発生したときはdesyncが疑われる - */ - public static InvalidOperationError = class extends Error {}; - - private myUserNumber: 1 | 2 | 3 | 4; - public state: PlayerState; - - constructor(myUserNumber: PlayerGameEngine['myUserNumber'], state: PlayerState) { - this.myUserNumber = myUserNumber; - this.state = state; - } - - public get myHouse(): House { - switch (this.myUserNumber) { - case 1: return this.state.user1House; - case 2: return this.state.user2House; - case 3: return this.state.user3House; - case 4: return this.state.user4House; - } - } - - public get myHandTiles(): Tile[] { - switch (this.myHouse) { - case 'e': return this.state.eHandTiles as Tile[]; - case 's': return this.state.sHandTiles as Tile[]; - case 'w': return this.state.wHandTiles as Tile[]; - case 'n': return this.state.nHandTiles as Tile[]; - } - } - - public get myHoTiles(): Tile[] { - switch (this.myHouse) { - case 'e': return this.state.eHoTiles; - case 's': return this.state.sHoTiles; - case 'w': return this.state.wHoTiles; - case 'n': return this.state.nHoTiles; - } - } - - public get isMeRiichi(): boolean { - switch (this.myHouse) { - case 'e': return this.state.eRiichi; - case 's': return this.state.sRiichi; - case 'w': return this.state.wRiichi; - case 'n': return this.state.nRiichi; - } - } - - public getHandTilesOf(house: House) { - switch (house) { - case 'e': return this.state.eHandTiles; - case 's': return this.state.sHandTiles; - case 'w': return this.state.wHandTiles; - case 'n': return this.state.nHandTiles; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public getHoTilesOf(house: House): Tile[] { - switch (house) { - case 'e': return this.state.eHoTiles; - case 's': return this.state.sHoTiles; - case 'w': return this.state.wHoTiles; - case 'n': return this.state.nHoTiles; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public getHurosOf(house: House): Huro[] { - switch (house) { - case 'e': return this.state.eHuros; - case 's': return this.state.sHuros; - case 'w': return this.state.wHuros; - case 'n': return this.state.nHuros; - default: throw new Error(`unrecognized house: ${house}`); - } - } - - public op_tsumo(house: House, tile: Tile) { - console.log('op_tsumo', this.state.turn, house, tile); - this.state.turn = house; - if (house === this.myHouse) { - this.myHandTiles.push(tile); - } else { - this.getHandTilesOf(house).push(null); - } - } - - public op_dahai(house: House, tile: Tile, riichi = false) { - console.log('op_dahai', this.state.turn, house, tile, riichi); - if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError(); - - if (riichi) { - switch (house) { - case 'e': this.state.eRiichi = true; break; - case 's': this.state.sRiichi = true; break; - case 'w': this.state.wRiichi = true; break; - case 'n': this.state.nRiichi = true; break; - } - } - - if (house === this.myHouse) { - this.myHandTiles.splice(this.myHandTiles.indexOf(tile), 1); - this.myHoTiles.push(tile); - } else { - this.getHandTilesOf(house).pop(); - this.getHoTilesOf(house).push(tile); - } - - this.state.turn = null; - - if (house === this.myHouse) { - } else { - const canRon = Utils.getHoraSets(this.myHandTiles.concat(tile)).length > 0; - const canPon = this.myHandTiles.filter(t => t === tile).length === 2; - - // TODO: canCii - - if (canRon) this.state.canRonSource = house; - if (canPon) this.state.canPonSource = house; - } - } - - /** - * ロンします - * @param source 牌を捨てた人 - * @param target ロンした人 - */ - public op_ron(source: House, target: House) { - this.state.canRonSource = null; - - const lastTile = this.getHoTilesOf(source).pop(); - if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError(); - if (target === this.myHouse) { - this.myHandTiles.push(lastTile); - } else { - this.getHandTilesOf(target).push(null); - } - this.state.turn = null; - } - - /** - * ポンします - * @param source 牌を捨てた人 - * @param target ポンした人 - */ - public op_pon(source: House, target: House) { - this.state.canPonSource = null; - - const lastTile = this.getHoTilesOf(source).pop(); - if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError(); - if (target === this.myHouse) { - this.myHandTiles.splice(this.myHandTiles.indexOf(lastTile), 1); - this.myHandTiles.splice(this.myHandTiles.indexOf(lastTile), 1); - } else { - this.getHandTilesOf(target).unshift(); - this.getHandTilesOf(target).unshift(); - } - this.getHurosOf(target).push({ type: 'pon', tile: lastTile, from: source }); - - this.state.turn = target; - } - - public op_nop() { - this.state.canRonSource = null; - this.state.canPonSource = null; - } -} - -const YAKU_DEFINITIONS = [{ - name: 'riichi', - fan: 1, - calc: (state: PlayerState, ctx: { tsumoTile: Tile; ronTile: Tile; }) => { - const house = state.turn; - return house === 'e' ? state.eRiichi : house === 's' ? state.sRiichi : house === 'w' ? state.wRiichi : state.nRiichi; - }, -}]; diff --git a/packages/misskey-mahjong/src/index.ts b/packages/misskey-mahjong/src/index.ts index c2cc36da3f..05b836f87a 100644 --- a/packages/misskey-mahjong/src/index.ts +++ b/packages/misskey-mahjong/src/index.ts @@ -3,7 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export * as Engine from './engine.js'; export * as Serializer from './serializer.js'; export * as Common from './common.js'; export * as Utils from './utils.js'; + +export { MasterGameEngine, MasterState } from './engine.master.js'; +export { PlayerGameEngine, PlayerState } from './engine.player.js'; diff --git a/packages/misskey-mahjong/src/serializer.ts b/packages/misskey-mahjong/src/serializer.ts index 94be1c6947..6bf1417d28 100644 --- a/packages/misskey-mahjong/src/serializer.ts +++ b/packages/misskey-mahjong/src/serializer.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Tile } from './engine.js'; +import { Tile } from './engine.player.js'; export type Log = { time: number;