diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts index 3e8b20a5a1..7b96d6e6ed 100644 --- a/packages/backend/src/core/MahjongService.ts +++ b/packages/backend/src/core/MahjongService.ts @@ -27,7 +27,7 @@ import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 5; // 5sec -const DAHAI_TIMEOUT_MS = 1000 * 30; // 30sec +const TURN_TIMEOUT_MS = 1000 * 30; // 30sec type Room = { id: string; @@ -297,7 +297,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } else if (res.type === 'ponned') { this.globalEventService.publishMahjongRoomStream(room.id, 'ponned', { source: res.source, target: res.target, tile: res.tile }); 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; - this.waitForDahai(room, userId, engine); + this.waitForTurn(room, userId, engine); } else if (res.type === 'kanned') { // TODO } else if (res.type === 'ronned') { @@ -308,23 +308,36 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { @bindThis private async next(room: Room, engine: Mahjong.Engine.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; if (aiHouses.includes(engine.state.turn)) { + // 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)); }, 500); - return; } else { - 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; - this.waitForDahai(room, userId, engine); + if (engine.isRiichiHouse(engine.state.turn)) { + // リーチ時はアガリ牌でない限りツモ切り + const handTiles = engine.getHandTilesOf(engine.state.turn); + const horaSets = Mahjong.Utils.getHoraSets(handTiles); + if (horaSets.length === 0) { + setTimeout(() => { + this.dahai(room, engine, engine.state.turn, handTiles.at(-1)); + }, 500); + } else { + this.waitForTurn(room, userId, engine); + } + } else { + this.waitForTurn(room, userId, engine); + } } } @bindThis - private async dahai(room: Room, engine: Mahjong.Engine.MasterGameEngine, house: Mahjong.Common.House, tile: Mahjong.Common.Tile) { - const res = engine.op_dahai(house, tile); + 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); room.gameState = engine.state; await this.saveRoom(room); @@ -345,6 +358,17 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { }, }; + // リーチ中はポン、チー、カンできない + if (engine.isRiichiHouse(res.canPonHouse)) { + answers.pon = false; + } + if (engine.isRiichiHouse(res.canCiiHouse)) { + answers.cii = false; + } + if (engine.isRiichiHouse(res.canKanHouse)) { + answers.kan = false; + } + if (aiHouses.includes(res.canPonHouse)) { // TODO: ちゃんと思考するようにする //answers.pon = Math.random() < 0.25; @@ -399,16 +423,22 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async op_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: string) { + public async op_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; - await this.redisClient.del(`mahjong:gameDahaiWaiting:${room.id}`); - const engine = new Mahjong.Engine.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; + } + + await this.clearTurnWaitingTimer(room.id); + await this.dahai(room, engine, myHouse, tile); } @@ -470,21 +500,29 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers)); } + /** + * プレイヤーの行動を待つ(打牌もしくはツモ和了) + * 制限時間が過ぎたらツモ切り + * NOTE: 時間切れチェックが行われたときにタイミングによっては次のwaitingが始まっている場合があることを考慮し、Setに一意のIDを格納する構造としている + * @param room + * @param userId + * @param engine + */ @bindThis - private async waitForDahai(room: Room, userId: MiUser['id'], engine: Mahjong.Engine.MasterGameEngine) { + private async waitForTurn(room: Room, userId: MiUser['id'], engine: Mahjong.Engine.MasterGameEngine) { const id = Math.random().toString(36).slice(2); - console.log('waitForDahai', userId, id); - this.redisClient.sadd(`mahjong:gameDahaiWaiting:${room.id}`, id); + console.log('waitForTurn', userId, id); + this.redisClient.sadd(`mahjong:gameTurnWaiting:${room.id}`, id); const waitingStartedAt = Date.now(); const interval = setInterval(async () => { - const waiting = await this.redisClient.sismember(`mahjong:gameDahaiWaiting:${room.id}`, id); + const waiting = await this.redisClient.sismember(`mahjong:gameTurnWaiting:${room.id}`, id); if (waiting === 0) { clearInterval(interval); return; } - if (Date.now() - waitingStartedAt > DAHAI_TIMEOUT_MS) { - await this.redisClient.srem(`mahjong:gameDahaiWaiting:${room.id}`, id); - console.log('dahai timeout', userId, id); + if (Date.now() - waitingStartedAt > TURN_TIMEOUT_MS) { + await this.redisClient.srem(`mahjong:gameTurnWaiting:${room.id}`, id); + 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); @@ -494,6 +532,15 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { }, 2000); } + /** + * プレイヤーが打牌またはツモ和了したら呼ぶ + * @param roomId + */ + @bindThis + private async clearTurnWaitingTimer(roomId: Room['id']) { + await this.redisClient.del(`mahjong:gameTurnWaiting:${roomId}`); + } + @bindThis public dispose(): void { } 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 e3556fbc8b..53d3b940c6 100644 --- a/packages/backend/src/server/api/stream/channels/mahjong-room.ts +++ b/packages/backend/src/server/api/stream/channels/mahjong-room.ts @@ -38,7 +38,7 @@ class MahjongRoomChannel extends Channel { case 'ready': this.ready(body); break; case 'updateSettings': this.updateSettings(body.key, body.value); break; case 'addAi': this.addAi(); break; - case 'dahai': this.dahai(body.tile); break; + case 'dahai': this.dahai(body.tile, body.riichi); break; case 'ron': this.ron(); break; case 'pon': this.pon(); break; case 'nop': this.nop(); break; @@ -68,10 +68,10 @@ class MahjongRoomChannel extends Channel { } @bindThis - private async dahai(tile: string) { + private async dahai(tile: string, riichi = false) { if (this.user == null) return; - this.mahjongService.op_dahai(this.roomId!, this.user, tile); + this.mahjongService.op_dahai(this.roomId!, this.user, tile, riichi); } @bindThis diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue index a38df8025e..45f4fe4b37 100644 --- a/packages/frontend/src/pages/mahjong/room.game.vue +++ b/packages/frontend/src/pages/mahjong/room.game.vue @@ -79,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only Ron Pon Skip - Tsumo + Tsumo + Riichi @@ -116,6 +117,10 @@ const isMyTurn = computed(() => { return engine.value.state.turn === engine.value.myHouse; }); +const canRiichi = computed(() => { + return Mahjong.Utils.getHoraTiles(engine.value.myHandTiles).length > 0; +}); + const canHora = computed(() => { return Mahjong.Utils.getHoraSets(engine.value.myHandTiles).length > 0; }); @@ -192,6 +197,19 @@ function dahai(tile: Mahjong.Common.Tile, ev: MouseEvent) { }); } +function riichi() { + if (!isMyTurn.value) return; + + engine.value.op_dahai(engine.value.myHouse, tile, true); + iTsumoed.value = false; + triggerRef(engine); + + props.connection!.send('dahai', { + tile: tile, + riichi: true, + }); +} + function ron() { engine.value.op_ron(engine.value.state.canRonSource, engine.value.myHouse); triggerRef(engine); diff --git a/packages/misskey-mahjong/src/engine.ts b/packages/misskey-mahjong/src/engine.ts index a71dca8e14..27371b86d3 100644 --- a/packages/misskey-mahjong/src/engine.ts +++ b/packages/misskey-mahjong/src/engine.ts @@ -166,7 +166,95 @@ export class MasterGameEngine { return tile; } - public op_dahai(house: House, tile: 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); @@ -174,6 +262,15 @@ export class MasterGameEngine { 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': @@ -332,7 +429,7 @@ export class MasterGameEngine { const target = this.state.ciiAsking.target; const tile = this.getHoTilesOf(source).pop()!; - this.getCiiedTilesOf(target).push({ tile, from: source }); + this.getHurosOf(target).push({ type: 'cii', tile, from: source }); clearAsking(); this.state.turn = target; @@ -357,64 +454,6 @@ export class MasterGameEngine { }; } - 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 createPlayerState(index: 1 | 2 | 3 | 4): PlayerState { const house = this.getHouse(index); @@ -544,6 +583,15 @@ export class PlayerGameEngine { } } + 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; @@ -584,10 +632,19 @@ export class PlayerGameEngine { } } - public op_dahai(house: House, tile: Tile) { - console.log('op_dahai', this.state.turn, house, tile); + 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);