diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index dcf643a7a5..5271c6f57b 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -222,11 +222,13 @@ export interface MahjongRoomEventTypes { tsumoTile: Mahjong.Common.Tile; }; ponned: { - source: Mahjong.Common.House; - target: Mahjong.Common.House; + caller: Mahjong.Common.House; + callee: Mahjong.Common.House; tile: Mahjong.Common.Tile; }; - endKyoku: { + ronned: { + }; + hora: { }; } //#endregion diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts index ddc2d2cc56..af40fea9b5 100644 --- a/packages/backend/src/core/MahjongService.ts +++ b/packages/backend/src/core/MahjongService.ts @@ -28,6 +28,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 TURN_TIMEOUT_MS = 1000 * 30; // 30sec +const NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS = 1000 * 15; // 15sec type Room = { id: string; @@ -69,6 +70,13 @@ type CallAndRonAnswers = { }; }; +type NextKyokuConfirmation = { + user1: boolean; + user2: boolean; + user3: boolean; + user4: boolean; +}; + @Injectable() export class MahjongService implements OnApplicationShutdown, OnModuleInit { private notificationService: NotificationService; @@ -267,17 +275,25 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { await this.saveRoom(room); - const packed = await this.packRoom(room); - this.globalEventService.publishMahjongRoomStream(room.id, 'started', { room: packed }); + this.globalEventService.publishMahjongRoomStream(room.id, 'started', { room: room }); return room; } @bindThis public async packRoom(room: Room, me: MiUser) { - return { - ...room, - }; + if (room.gameState) { + const engine = new Mahjong.MasterGameEngine(room.gameState); + const myIndex = room.user1Id === me.id ? 1 : room.user2Id === me.id ? 2 : room.user3Id === me.id ? 3 : 4; + return { + ...room, + gameState: engine.createPlayerState(myIndex), + }; + } else { + return { + ...room, + }; + } } @bindThis @@ -295,13 +311,14 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { this.globalEventService.publishMahjongRoomStream(room.id, 'tsumo', { house: res.house, tile: res.tile }); this.next(room, engine); } else if (res.type === 'ponned') { - this.globalEventService.publishMahjongRoomStream(room.id, 'ponned', { source: res.source, target: res.target, tile: res.tile }); + this.globalEventService.publishMahjongRoomStream(room.id, 'ponned', { caller: res.caller, callee: res.callee, 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.waitForTurn(room, userId, engine); } else if (res.type === 'kanned') { // TODO - } else if (res.type === 'endKyoku') { - // TODO + } else if (res.type === 'ronned') { + this.globalEventService.publishMahjongRoomStream(room.id, 'ronned', { }); + this.endKyoku(room, engine); } } @@ -336,6 +353,28 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { @bindThis private async endKyoku(room: Room, engine: Mahjong.MasterGameEngine) { + const confirmation: NextKyokuConfirmation = { + user1: false, + user2: false, + user3: false, + user4: false, + }; + this.redisClient.set(`mahjong:gameNextKyokuConfirmation:${room.id}`, JSON.stringify(confirmation)); + const waitingStartedAt = Date.now(); + const interval = setInterval(async () => { + const confirmationRaw = await this.redisClient.get(`mahjong:gameNextKyokuConfirmation:${room.id}`); + if (confirmationRaw == null) { + clearInterval(interval); + return; + } + const confirmation = JSON.parse(confirmationRaw) as NextKyokuConfirmation; + const allConfirmed = confirmation.user1 && confirmation.user2 && confirmation.user3 && confirmation.user4; + if (allConfirmed || (Date.now() - waitingStartedAt > NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS)) { + await this.redisClient.del(`mahjong:gameNextKyokuConfirmation:${room.id}`); + clearInterval(interval); + this.nextKyoku(room, engine); + } + }, 2000); } @bindThis @@ -425,6 +464,23 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } } + @bindThis + public async confirmNextKyoku(roomId: Room['id'], user: MiUser) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const confirmationRaw = await this.redisClient.get(`mahjong:gameNextKyokuConfirmation:${room.id}`); + if (confirmationRaw == null) return; + const confirmation = JSON.parse(confirmationRaw) as NextKyokuConfirmation; + if (user.id === room.user1Id) confirmation.user1 = true; + if (user.id === room.user2Id) confirmation.user2 = true; + if (user.id === room.user3Id) confirmation.user3 = true; + if (user.id === room.user4Id) confirmation.user4 = true; + await this.redisClient.set(`mahjong:gameNextKyokuConfirmation:${room.id}`, JSON.stringify(confirmation)); + } + @bindThis public async commit_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: string, riichi = false) { const room = await this.getRoom(roomId); @@ -528,10 +584,10 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { 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; + if (engine.state.ponAsking?.caller === myHouse) currentAnswers.pon = false; + if (engine.state.ciiAsking?.caller === myHouse) currentAnswers.cii = false; + if (engine.state.kanAsking?.caller === myHouse) currentAnswers.kan = false; + if (engine.state.ronAsking != null && engine.state.ronAsking.callers.includes(myHouse)) currentAnswers.ron[myHouse] = false; await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers)); } 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 51f953443a..5ac5862063 100644 --- a/packages/backend/src/server/api/stream/channels/mahjong-room.ts +++ b/packages/backend/src/server/api/stream/channels/mahjong-room.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { MahjongService } from '@/core/MahjongService.js'; +import { GlobalEvents } from '@/core/GlobalEventService.js'; import Channel, { type MiChannelService } from '../channel.js'; class MahjongRoomChannel extends Channel { @@ -29,7 +30,19 @@ class MahjongRoomChannel extends Channel { public async init(params: any) { this.roomId = params.roomId as string; - this.subscriber.on(`mahjongRoomStream:${this.roomId}`, this.send); + this.subscriber.on(`mahjongRoomStream:${this.roomId}`, this.onMahjongRoomStreamMessage); + } + + @bindThis + private async onMahjongRoomStreamMessage(message: GlobalEvents['mahjongRoom']['payload']) { + if (message.type === 'started') { + const packed = await this.mahjongService.packRoom(message.body.room, this.user!); + this.send('started', { + room: packed, + }); + } else { + this.send(message.type, message.body); + } } @bindThis @@ -38,6 +51,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 'confirmNextKyoku': this.confirmNextKyoku(); break; case 'dahai': this.dahai(body.tile, body.riichi); break; case 'hora': this.hora(); break; case 'ron': this.ron(); break; @@ -61,6 +75,13 @@ class MahjongRoomChannel extends Channel { this.mahjongService.changeReadyState(this.roomId!, this.user, ready); } + @bindThis + private async confirmNextKyoku() { + if (this.user == null) return; + + this.mahjongService.confirmNextKyoku(this.roomId!, this.user); + } + @bindThis private async addAi() { if (this.user == null) return; @@ -113,7 +134,7 @@ class MahjongRoomChannel extends Channel { @bindThis public dispose() { // Unsubscribe events - this.subscriber.off(`mahjongRoomStream:${this.roomId}`, this.send); + this.subscriber.off(`mahjongRoomStream:${this.roomId}`, this.onMahjongRoomStreamMessage); } } diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue index 68e9542d74..b62786cd28 100644 --- a/packages/frontend/src/pages/mahjong/room.game.vue +++ b/packages/frontend/src/pages/mahjong/room.game.vue @@ -6,6 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -117,10 +125,6 @@ const isMyTurn = computed(() => { return engine.value.state.turn === engine.value.myHouse; }); -const canRiichi = computed(() => { - return Mahjong.Utils.getTilesForRiichi(engine.value.myHandTiles).length > 0; -}); - const canHora = computed(() => { return Mahjong.Utils.getHoraSets(engine.value.myHandTiles).length > 0; }); @@ -248,9 +252,6 @@ function hora() { } function ron() { - engine.value.commit_ron(engine.value.state.canRonSource, engine.value.myHouse); - triggerRef(engine); - props.connection!.send('ron', { }); } @@ -353,12 +354,27 @@ function onStreamPonned(log) { // return; //} - engine.value.commit_pon(log.source, log.target); + engine.value.commit_pon(log.caller, log.callee); triggerRef(engine); myTurnTimerRmain.value = room.value.timeLimitForEachTurn; } +function onStreamRonned(log) { + console.log('onStreamRonned', log); + + engine.value.commit_ron(log.callers, log.callee); + triggerRef(engine); + + alert('end kyoku'); +} + +function onStreamHora(log) { + console.log('onStreamHora', log); + + window.alert('end kyoku'); +} + function restoreRoom(_room) { room.value = deepClone(_room); @@ -371,6 +387,8 @@ onMounted(() => { props.connection.on('tsumo', onStreamTsumo); props.connection.on('dahaiAndTsumo', onStreamDahaiAndTsumo); props.connection.on('ponned', onStreamPonned); + props.connection.on('ronned', onStreamRonned); + props.connection.on('hora', onStreamHora); } }); @@ -380,6 +398,8 @@ onActivated(() => { props.connection.on('tsumo', onStreamTsumo); props.connection.on('dahaiAndTsumo', onStreamDahaiAndTsumo); props.connection.on('ponned', onStreamPonned); + props.connection.on('ronned', onStreamRonned); + props.connection.on('hora', onStreamHora); } }); @@ -389,6 +409,8 @@ onDeactivated(() => { props.connection.off('tsumo', onStreamTsumo); props.connection.off('dahaiAndTsumo', onStreamDahaiAndTsumo); props.connection.off('ponned', onStreamPonned); + props.connection.off('ronned', onStreamRonned); + props.connection.off('hora', onStreamHora); } }); @@ -398,6 +420,8 @@ onUnmounted(() => { props.connection.off('tsumo', onStreamTsumo); props.connection.off('dahaiAndTsumo', onStreamDahaiAndTsumo); props.connection.off('ponned', onStreamPonned); + props.connection.off('ronned', onStreamRonned); + props.connection.off('hora', onStreamHora); } }); @@ -418,6 +442,15 @@ onUnmounted(() => { box-sizing: border-box; } +.centerPanel { + position: absolute; + display: grid; + place-items: center; + width: 100%; + height: 100%; + scale: 0.8; +} + .handTilesOfToimen { position: absolute; top: 0; diff --git a/packages/misskey-mahjong/src/common.ts b/packages/misskey-mahjong/src/common.ts index 4816095f82..349d23f335 100644 --- a/packages/misskey-mahjong/src/common.ts +++ b/packages/misskey-mahjong/src/common.ts @@ -172,7 +172,7 @@ type EnvForCalcYaku = { riichi: boolean; }; -const YAKU_DEFINITIONS = [{ +export const YAKU_DEFINITIONS = [{ name: 'riichi', fan: 1, calc: (state: EnvForCalcYaku) => { diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts index 539b1d25d9..6da22b23ec 100644 --- a/packages/misskey-mahjong/src/engine.master.ts +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -4,7 +4,7 @@ */ import CRC32 from 'crc-32'; -import { Tile, House, Huro, TILE_TYPES } from './common.js'; +import { Tile, House, Huro, TILE_TYPES, YAKU_DEFINITIONS } from './common.js'; import * as Utils from './utils.js'; import { PlayerState } from './engine.player.js'; @@ -60,48 +60,48 @@ export type MasterState = { /** * 牌を捨てた人 */ - source: House; + callee: House; /** * ロンする権利がある人 */ - targets: House[]; + callers: House[]; } | null; ponAsking: { /** * 牌を捨てた人 */ - source: House; + callee: House; /** * ポンする権利がある人 */ - target: House; + caller: House; } | null; ciiAsking: { /** * 牌を捨てた人 */ - source: House; + callee: House; /** - * チーする権利がある人(sourceの下家なのは自明だがプログラム簡略化のため) + * チーする権利がある人(calleeの下家なのは自明だがプログラム簡略化のため) */ - target: House; + caller: House; } | null; kanAsking: { /** * 牌を捨てた人 */ - source: House; + callee: House; /** * カンする権利がある人 */ - target: House; + caller: House; } | null; }; @@ -113,10 +113,18 @@ export class MasterGameEngine { } public static createInitialState(): MasterState { + const ikasama: Tile[] = ['haku', 'm2', 'm3', 'p5', 'p6', 'p7', 's2', 's3', 's4', 'chun', 'chun', 'chun', 'n', 'n']; + 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); + for (const tile of ikasama) { + const index = tiles.indexOf(tile); + tiles.splice(index, 1); + } + + //const eHandTiles = tiles.splice(0, 14); + const eHandTiles = ikasama; const sHandTiles = tiles.splice(0, 13); const wHandTiles = tiles.splice(0, 13); const nHandTiles = tiles.splice(0, 13); @@ -205,11 +213,30 @@ export class MasterGameEngine { } private endKyoku() { + console.log('endKyoku'); const newState = MasterGameEngine.createInitialState(); newState.kyoku = this.state.kyoku + 1; newState.points = this.state.points; } + private ron(callers: House[], callee: House) { + for (const house of callers) { + const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ + house: house, + handTiles: this.state.handTiles[house], + huros: this.state.huros[house], + tsumoTile: null, + ronTile: this.state.hoTiles[callee].at(-1)!, + riichi: this.state.riichis[house], + })); + console.log('yakus', yakus); + } + + this.endKyoku(); + + // TODO: 役情報を返す + } + public commit_dahai(house: House, tile: Tile, riichi = false) { if (this.state.turn !== house) throw new Error('Not your turn'); @@ -280,26 +307,26 @@ export class MasterGameEngine { if (canRonHouses.length > 0 || canPonHouse != null) { if (canRonHouses.length > 0) { this.state.ronAsking = { - source: house, - targets: canRonHouses, + callee: house, + callers: canRonHouses, }; } if (canKanHouse != null) { this.state.kanAsking = { - source: house, - target: canKanHouse, + callee: house, + caller: canKanHouse, }; } if (canPonHouse != null) { this.state.ponAsking = { - source: house, - target: canPonHouse, + callee: house, + caller: canPonHouse, }; } if (canCiiHouse != null) { this.state.ciiAsking = { - source: house, - target: canCiiHouse, + callee: house, + caller: canCiiHouse, }; } this.state.turn = null; @@ -354,58 +381,57 @@ export class MasterGameEngine { }; if (this.state.ronAsking != null && answers.ron.length > 0) { - // TODO - this.endKyoku(); + this.ron(answers.ron, this.state.ronAsking.callee); return { - type: 'endKyoku', + type: 'ronned', }; } if (this.state.kanAsking != null && answers.kan) { - const source = this.state.kanAsking.source; - const target = this.state.kanAsking.target; + const caller = this.state.kanAsking.caller; + const callee = this.state.kanAsking.callee; - const tile = this.state.hoTiles[source].pop()!; - this.state.huros[target].push({ type: 'minkan', tile, from: source }); + const tile = this.state.hoTiles[callee].pop()!; + this.state.huros[caller].push({ type: 'minkan', tile, from: callee }); clearAsking(); - this.state.turn = target; + this.state.turn = caller; // TODO return; } if (this.state.ponAsking != null && answers.pon) { - const source = this.state.ponAsking.source; - const target = this.state.ponAsking.target; + const caller = this.state.ponAsking.caller; + const callee = this.state.ponAsking.callee; - 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 }); + const tile = this.state.hoTiles[callee].pop()!; + this.state.handTiles[caller].splice(this.state.handTiles[caller].indexOf(tile), 1); + this.state.handTiles[caller].splice(this.state.handTiles[caller].indexOf(tile), 1); + this.state.huros[caller].push({ type: 'pon', tile, from: callee }); clearAsking(); - this.state.turn = target; + this.state.turn = caller; return { type: 'ponned', - source, - target, + caller, + callee, tile, }; } if (this.state.ciiAsking != null && answers.cii) { - const source = this.state.ciiAsking.source; - const target = this.state.ciiAsking.target; + const caller = this.state.ciiAsking.caller; + const callee = this.state.ciiAsking.callee; - const tile = this.state.hoTiles[source].pop()!; - this.state.huros[target].push({ type: 'cii', tile, from: source }); + const tile = this.state.hoTiles[callee].pop()!; + this.state.huros[caller].push({ type: 'cii', tile, from: callee }); clearAsking(); - this.state.turn = target; + this.state.turn = caller; return { type: 'ciied', - source, - target, + caller, + callee, tile, }; } diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts index 3d2881048f..ef19e13a9e 100644 --- a/packages/misskey-mahjong/src/engine.player.ts +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -4,7 +4,7 @@ */ import CRC32 from 'crc-32'; -import { Tile, House, Huro, TILE_TYPES } from './common.js'; +import { Tile, House, Huro, TILE_TYPES, YAKU_DEFINITIONS } from './common.js'; import * as Utils from './utils.js'; export type PlayerState = { @@ -96,6 +96,7 @@ export class PlayerGameEngine { public commit_tsumo(house: House, tile: Tile) { console.log('commit_tsumo', this.state.turn, house, tile); + this.state.tilesCount--; this.state.turn = house; if (house === this.myHouse) { this.myHandTiles.push(tile); @@ -141,51 +142,73 @@ export class PlayerGameEngine { public commit_hora(house: House) { console.log('commit_hora', this.state.turn, house); - if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError(); + + // TODO: ツモした人の手牌情報を貰う必要がある } /** * ロンします - * @param source 牌を捨てた人 - * @param target ロンした人 + * @param callers ロンした人 + * @param callee 牌を捨てた人 */ - public commit_ron(source: House, target: House) { + public commit_ron(callers: House[], callee: House) { + console.log('commit_ron', this.state.turn, callers, callee); + 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); + // TODO: ロンした人の手牌情報を貰う必要がある + + for (const house of callers) { + const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ + house: house, + handTiles: this.state.handTiles[house], + huros: this.state.huros[house], + tsumoTile: null, + ronTile: this.state.hoTiles[callee].at(-1)!, + riichi: this.state.riichis[house], + })); + console.log('yakus', yakus); } - this.state.turn = null; } /** * ポンします - * @param source 牌を捨てた人 - * @param target ポンした人 + * @param caller ポンした人 + * @param callee 牌を捨てた人 */ - public commit_pon(source: House, target: House) { + public commit_pon(caller: House, callee: House) { this.state.canPonSource = null; - const lastTile = this.state.hoTiles[source].pop(); + const lastTile = this.state.hoTiles[callee].pop(); if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError(); - if (target === this.myHouse) { + if (caller === 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.handTiles[caller].unshift(); + this.state.handTiles[caller].unshift(); } - this.state.huros[target].push({ type: 'pon', tile: lastTile, from: source }); + this.state.huros[caller].push({ type: 'pon', tile: lastTile, from: callee }); - this.state.turn = target; + this.state.turn = caller; } public commit_nop() { this.state.canRonSource = null; this.state.canPonSource = null; } + + public get isMenzen(): boolean { + const calls = ['pon', 'cii', 'minkan']; + return this.state.huros[this.myHouse].filter(h => calls.includes(h.type)).length === 0; + } + + public canRiichi(): boolean { + if (this.state.turn !== this.myHouse) return false; + if (this.state.riichis[this.myHouse]) return false; + if (this.state.points[this.myHouse] < 1000) return false; + if (!this.isMenzen) return false; + if (Utils.getTilesForRiichi(this.myHandTiles).length === 0) return false; + return true; + } }