diff --git a/locales/index.d.ts b/locales/index.d.ts index be020be9b1..e1e829ac20 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9629,6 +9629,10 @@ export interface Locale extends ILocale { * 退室 */ "leave": string; + /** + * CPUを追加 + */ + "addCpu": string; }; "_offlineScreen": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fa0dbdbb10..a330e26995 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2566,6 +2566,7 @@ _mahjong: ready: "準備完了" cancelReady: "準備を再開" leave: "退室" + addCpu: "CPUを追加" _offlineScreen: title: "オフライン - サーバーに接続できません" diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index a2fc255e05..57c1b3da40 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -201,18 +201,21 @@ export interface MahjongRoomEventTypes { user3: boolean; user4: boolean; }; + started: { + room: Packed<'MahjongRoomDetailed'>; + }; tsumo: { house: Mahjong.Engine.House; - tile: Mahjong.Engine.Tile; + tile: Mahjong.Common.Tile; }; dahai: { house: Mahjong.Engine.House; - tile: Mahjong.Engine.Tile; + tile: Mahjong.Common.Tile; }; dahaiAndTsumo: { house: Mahjong.Engine.House; - dahaiTile: Mahjong.Engine.Tile; - tsumoTile: Mahjong.Engine.Tile; + dahaiTile: Mahjong.Common.Tile; + tsumoTile: Mahjong.Common.Tile; }; } //#endregion diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts index 4d385a0399..bf4bafa9a9 100644 --- a/packages/backend/src/core/MahjongService.ts +++ b/packages/backend/src/core/MahjongService.ts @@ -26,7 +26,7 @@ import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec -const PON_TIMEOUT_MS = 1000 * 10; // 10sec +const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 5; // 5sec const DAHAI_TIMEOUT_MS = 1000 * 30; // 30sec type Room = { @@ -52,10 +52,23 @@ type Room = { user3Offline?: boolean; user4Offline?: boolean; isStarted?: boolean; + timeLimitForEachTurn: number; gameState?: Mahjong.Engine.MasterState; }; +type CallAndRonAnswers = { + pon: null | boolean; + cii: null | boolean; + kan: null | boolean; + ron: { + e: null | boolean; + s: null | boolean; + w: null | boolean; + n: null | boolean; + }; +}; + @Injectable() export class MahjongService implements OnApplicationShutdown, OnModuleInit { private notificationService: NotificationService; @@ -98,6 +111,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { user2Ready: false, user3Ready: false, user4Ready: false, + timeLimitForEachTurn: 30, }; await this.saveRoom(room); return room; @@ -152,21 +166,21 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { if (!room) return null; if (room.user1Id !== user.id) throw new Error('access denied'); - if (room.user2Id == null) { + if (room.user2Id == null && !room.user2Ai) { room.user2Ai = true; room.user2Ready = true; await this.saveRoom(room); this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 2, user: null }); return room; } - if (room.user3Id == null) { + if (room.user3Id == null && !room.user3Ai) { room.user3Ai = true; room.user3Ready = true; await this.saveRoom(room); this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 3, user: null }); return room; } - if (room.user4Id == null) { + if (room.user4Id == null && !room.user4Ai) { room.user4Ai = true; room.user4Ready = true; await this.saveRoom(room); @@ -267,43 +281,129 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - private async dahai(room: Room, engine: Mahjong.Engine.MasterGameEngine, user: MiUser, tile: Mahjong.Engine.Tile) { - 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; - const res = engine.op_dahai(myHouse, tile); - if (res.canPonHouse) { - // TODO: 家がCPUだった場合の処理 - this.redisClient.set(`mahjong:gamePonAsking:${room.id}`, ''); - const waitingStartedAt = Date.now(); - const interval = setInterval(async () => { - const waiting = await this.redisClient.get(`mahjong:gamePonAsking:${room.id}`); - if (waiting == null) { - clearInterval(interval); - return; - } - if (Date.now() - waitingStartedAt > PON_TIMEOUT_MS) { - await this.redisClient.del(`mahjong:gamePonAsking:${room.id}`); - clearInterval(interval); - const res = engine.op_noOnePon(); - this.globalEventService.publishMahjongRoomStream(room.id, 'tsumo', { house: res.house, tile: res.tile }); - return; - } - }, 2000); - this.globalEventService.publishMahjongRoomStream(room.id, 'dahai', { house: myHouse, tile }); - } else { - this.globalEventService.publishMahjongRoomStream(room.id, 'dahaiAndTsumo', { house: myHouse, dahaiTile: tile, tsumoTile: res.tsumoTile }); + private async answer(room: Room, engine: Mahjong.Engine.MasterGameEngine, answers: CallAndRonAnswers) { + const res = engine.op_resolveCallAndRonInterruption({ + pon: answers.pon ?? false, + cii: answers.cii ?? false, + kan: answers.kan ?? false, + ron: [...(answers.ron.e ? ['e'] : []), ...(answers.ron.s ? ['s'] : []), ...(answers.ron.w ? ['w'] : []), ...(answers.ron.n ? ['n'] : [])], + }); + room.gameState = engine.state; + await this.saveRoom(room); + + if (res.type === 'tsumo') { + this.globalEventService.publishMahjongRoomStream(room.id, 'log', { operation: 'tsumo', house: res.house, tile: res.tile }); + this.next(room, engine); + } else if (res.type === 'ponned') { + this.globalEventService.publishMahjongRoomStream(room.id, 'log', { operation: 'ponned', house: res.house, 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); + } else if (res.type === 'kanned') { + // TODO + } else if (res.type === 'ronned') { + // TODO } } @bindThis - public async op_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: string) { + 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)); + + if (aiHouses.includes(engine.state.turn)) { + 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)); + }, 1000); + 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); + } + } + + @bindThis + private async dahai(room: Room, engine: Mahjong.Engine.MasterGameEngine, house: Mahjong.Common.House, tile: Mahjong.Common.Tile, operationId?: string) { + const res = engine.op_dahai(house, tile); + room.gameState = engine.state; + await this.saveRoom(room); + + const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => engine.getHouse(id)); + + if (res.asking) { + console.log('asking', res); + + const answers: CallAndRonAnswers = { + pon: null, + cii: null, + kan: null, + ron: { + e: null, + s: null, + w: null, + n: null, + }, + }; + + if (aiHouses.includes(res.canPonHouse)) { + // TODO + } + if (aiHouses.includes(res.canChiHouse)) { + // TODO + } + if (aiHouses.includes(res.canKanHouse)) { + // TODO + } + for (const h of res.canRonHouses) { + if (aiHouses.includes(h)) { + // TODO + } + } + + this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(answers)); + const waitingStartedAt = Date.now(); + const interval = setInterval(async () => { + const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`); + if (current == null) throw new Error('arienai (gameCallAndRonAsking)'); + const currentAnswers = JSON.parse(current) as CallAndRonAnswers; + const allAnswered = !( + (res.canPonHouse != null && currentAnswers.pon == null) || + (res.canCiiHouse != null && currentAnswers.cii == null) || + (res.canKanHouse != null && currentAnswers.kan == null) || + (res.canRonHouses.includes('e') && currentAnswers.ron.e == null) || + (res.canRonHouses.includes('s') && currentAnswers.ron.s == null) || + (res.canRonHouses.includes('w') && currentAnswers.ron.w == null) || + (res.canRonHouses.includes('n') && currentAnswers.ron.n == null) + ); + if (allAnswered || (Date.now() - waitingStartedAt > CALL_AND_RON_ASKING_TIMEOUT_MS)) { + console.log('answerd'); + await this.redisClient.del(`mahjong:gamePonAsking:${room.id}`); + clearInterval(interval); + this.answer(room, engine, currentAnswers); + return; + } + }, 2000); + + this.globalEventService.publishMahjongRoomStream(room.id, 'log', { operation: 'dahai', house: house, tile, id: operationId }); + } else { + this.globalEventService.publishMahjongRoomStream(room.id, 'log', { operation: 'dahaiAndTsumo', house: house, dahaiTile: tile, tsumoTile: res.tsumoTile, id: operationId }); + + this.next(room, engine); + } + } + + @bindThis + public async op_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: string, operationId: string) { 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); - await this.dahai(room, engine, user, tile); + 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; + await this.dahai(room, engine, myHouse, tile, operationId); } @bindThis @@ -314,26 +414,56 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { 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; - const res = engine.op_pon(myHouse); - this.waitForDahai(room, user, engine); + + // TODO: 自分にポン回答する権利がある状態かバリデーション + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallAndRonAnswers; + currentAnswers.pon = true; + await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers)); } @bindThis - private async waitForDahai(game: Room, user: MiUser, engine: Mahjong.Engine.MasterGameEngine) { - this.redisClient.set(`mahjong:gameDahaiWaiting:${game.id}`, ''); + public async op_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 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が必要 + const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallAndRonAnswers; + if (engine.state.ponAsking?.target === myHouse) currentAnswers.pon = false; + if (engine.state.ciiAsking?.target === myHouse) currentAnswers.cii = false; + if (engine.state.kanAsking?.target === myHouse) currentAnswers.kan = false; + if (engine.state.ronAsking != null && engine.state.ronAsking.targets.includes(myHouse)) currentAnswers.ron[myHouse] = false; + await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers)); + } + + @bindThis + private async waitForDahai(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); const waitingStartedAt = Date.now(); const interval = setInterval(async () => { - const waiting = await this.redisClient.get(`mahjong:gameDahaiWaiting:${game.id}`); - if (waiting == null) { + const waiting = await this.redisClient.sismember(`mahjong:gameDahaiWaiting:${room.id}`, id); + if (waiting === 0) { clearInterval(interval); return; } if (Date.now() - waitingStartedAt > DAHAI_TIMEOUT_MS) { - await this.redisClient.del(`mahjong:gameDahaiWaiting:${game.id}`); + await this.redisClient.srem(`mahjong:gameDahaiWaiting:${room.id}`, id); + console.log('dahai timeout', userId, id); clearInterval(interval); - const house = game.user1Id === user.id ? engine.state.user1House : game.user2Id === user.id ? engine.state.user2House : game.user3Id === user.id ? engine.state.user3House : engine.state.user4House; + 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 = house === 'e' ? engine.state.eHandTiles : house === 's' ? engine.state.sHandTiles : house === 'w' ? engine.state.wHandTiles : engine.state.nHandTiles; - await this.dahai(game, engine, user, handTiles.at(-1)); + await this.dahai(room, engine, house, handTiles.at(-1)); return; } }, 2000); diff --git a/packages/backend/src/models/json-schema/mahjong-room.ts b/packages/backend/src/models/json-schema/mahjong-room.ts index 71d5f268ff..ce666b2b83 100644 --- a/packages/backend/src/models/json-schema/mahjong-room.ts +++ b/packages/backend/src/models/json-schema/mahjong-room.ts @@ -106,5 +106,9 @@ export const packedMahjongRoomDetailedSchema = { type: 'boolean', optional: false, nullable: false, }, + timeLimitForEachTurn: { + type: 'number', + optional: false, nullable: false, + }, }, } as const; 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 ebdd6bd607..0e55a62b8f 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,9 @@ 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 'putStone': this.putStone(body.pos, body.id); break; + case 'dahai': this.dahai(body.tile, body.id); break; + case 'pon': this.pon(); break; + case 'nop': this.nop(); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break; } } @@ -65,10 +67,24 @@ class MahjongRoomChannel extends Channel { } @bindThis - private async putStone(pos: number, id: string) { + private async dahai(tile: string, id: string) { if (this.user == null) return; - this.mahjongService.putStoneToRoom(this.roomId!, this.user, pos, id); + this.mahjongService.op_dahai(this.roomId!, this.user, tile, id); + } + + @bindThis + private async pon() { + if (this.user == null) return; + + this.mahjongService.op_pon(this.roomId!, this.user); + } + + @bindThis + private async nop() { + if (this.user == null) return; + + this.mahjongService.op_nop(this.roomId!, this.user); } @bindThis diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue index 99b1a7a104..cb9ddfb592 100644 --- a/packages/frontend/src/pages/mahjong/room.game.vue +++ b/packages/frontend/src/pages/mahjong/room.game.vue @@ -5,6 +5,45 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -28,18 +67,22 @@ import { confetti } from '@/scripts/confetti.js'; const $i = signinRequired(); const props = defineProps<{ - room: Misskey.entities.ReversiRoomDetailed; + room: Misskey.entities.MahjongRoomDetailed; connection?: Misskey.ChannelConnection | null; }>(); -const room = ref(deepClone(props.room)); +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, room.value.gameState)); +const engine = shallowRef(new Mahjong.Engine.PlayerGameEngine(myUserNumber.value, room.value.gameState)); const isMyTurn = computed(() => { return engine.value.state.turn === engine.value.myHouse; }); +const canHora = computed(() => { + return Mahjong.Utils.getHoraSets(engine.value.myHandTiles).length > 0; +}); + /* if (room.value.isStarted && !room.value.isEnded) { useInterval(() => { @@ -84,22 +127,53 @@ if (!props.room.isEnded) { } */ +function dahai(tile: Mahjong.Common.Tile, ev: MouseEvent) { + if (!isMyTurn.value) return; + + engine.value.op_dahai(engine.value.myHouse, tile); + + const id = Math.random().toString(36).slice(2); + appliedOps.push(id); + props.connection!.send('dahai', { + tile: tile, + id, + }); +} + +function pon() { + engine.value.op_pon(engine.value.canPonTo, engine.value.myHouse); + + const id = Math.random().toString(36).slice(2); + appliedOps.push(id); + props.connection!.send('pon', { + id, + }); +} + +function skip() { + engine.value.op_nop(engine.value.myHouse); + + const id = Math.random().toString(36).slice(2); + appliedOps.push(id); + props.connection!.send('nop', {}); +} + async function onStreamLog(log) { if (log.id == null || !appliedOps.includes(log.id)) { switch (log.operation) { - case 'put': { + case 'dahai': { sound.playUrl('/client-assets/mahjong/dahai.mp3', { volume: 1, playbackRate: 1, }); - if (log.house !== engine.value.turn) { // = desyncが発生している - const _room = await misskeyApi('reversi/show-room', { - roomId: props.room.id, - }); - restoreRoom(_room); - return; - } + //if (log.house !== engine.value.state.turn) { // = desyncが発生している + // const _room = await misskeyApi('mahjong/show-room', { + // roomId: props.room.id, + // }); + // restoreRoom(_room); + // return; + //} engine.value.op_dahai(log.house, log.tile); triggerRef(engine); @@ -109,6 +183,33 @@ async function onStreamLog(log) { break; } + case 'dahaiAndTsumo': { + sound.playUrl('/client-assets/mahjong/dahai.mp3', { + volume: 1, + playbackRate: 1, + }); + + //if (log.house !== engine.value.state.turn) { // = desyncが発生している + // const _room = await misskeyApi('mahjong/show-room', { + // roomId: props.room.id, + // }); + // restoreRoom(_room); + // return; + //} + + engine.value.op_dahai(log.house, log.dahaiTile); + triggerRef(engine); + + window.setTimeout(() => { + engine.value.op_tsumo(Mahjong.Utils.nextHouse(log.house), log.tsumoTile); + triggerRef(engine); + }, 1000); + + myTurnTimerRmain.value = room.value.timeLimitForEachTurn; + opTurnTimerRmain.value = room.value.timeLimitForEachTurn; + break; + } + default: break; } @@ -124,184 +225,27 @@ function restoreRoom(_room) { onMounted(() => { if (props.connection != null) { props.connection.on('log', onStreamLog); - props.connection.on('ended', onStreamEnded); } }); onActivated(() => { if (props.connection != null) { props.connection.on('log', onStreamLog); - props.connection.on('ended', onStreamEnded); } }); onDeactivated(() => { if (props.connection != null) { props.connection.off('log', onStreamLog); - props.connection.off('ended', onStreamEnded); } }); onUnmounted(() => { if (props.connection != null) { props.connection.off('log', onStreamLog); - props.connection.off('ended', onStreamEnded); } }); diff --git a/packages/frontend/src/pages/mahjong/room.setting.vue b/packages/frontend/src/pages/mahjong/room.setting.vue index cc433fed42..e8c5ae8512 100644 --- a/packages/frontend/src/pages/mahjong/room.setting.vue +++ b/packages/frontend/src/pages/mahjong/room.setting.vue @@ -28,6 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only
OK
+
+ {{ i18n.ts._mahjong.addCpu }} +