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);