From 00bf57d2432fe6dedd5ac05b0b58d4199adea41a Mon Sep 17 00:00:00 2001 From: syuilo Date: Sat, 3 Feb 2024 18:02:00 +0900 Subject: [PATCH] wip --- packages/backend/src/core/MahjongService.ts | 80 +++++++++++++------ .../api/stream/channels/mahjong-room.ts | 32 ++++++++ .../frontend/src/pages/mahjong/room.game.vue | 67 ++++++++++++++-- packages/misskey-mahjong/src/engine.master.ts | 44 ++++------ packages/misskey-mahjong/src/engine.player.ts | 52 ++++++++---- 5 files changed, 203 insertions(+), 72 deletions(-) diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts index bd89ffcc11..8957edd4e8 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 CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 7; // 7sec +const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 10; // 10sec const TURN_TIMEOUT_MS = 1000 * 30; // 30sec const NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS = 1000 * 15; // 15sec @@ -58,9 +58,9 @@ type Room = { gameState?: Mahjong.MasterState; }; -type CallAndRonAnswers = { +type CallingAnswers = { pon: null | boolean; - cii: null | boolean; + cii: null | false | [Mahjong.Tile, Mahjong.Tile, Mahjong.Tile]; kan: null | boolean; ron: { e: null | boolean; @@ -305,8 +305,8 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - private async answer(room: Room, engine: Mahjong.MasterGameEngine, answers: CallAndRonAnswers) { - const res = engine.commit_resolveCallAndRonInterruption({ + private async answer(room: Room, engine: Mahjong.MasterGameEngine, answers: CallingAnswers) { + const res = engine.commit_resolveCallingInterruption({ pon: answers.pon ?? false, cii: answers.cii ?? false, kan: answers.kan ?? false, @@ -386,7 +386,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { if (res.asking) { console.log('asking', res); - const answers: CallAndRonAnswers = { + const answers: CallingAnswers = { pon: null, cii: null, kan: null, @@ -428,12 +428,12 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } } - this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(answers)); + this.redisClient.set(`mahjong:gameCallingAsking:${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 current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('arienai (gameCallingAsking)'); + const currentAnswers = JSON.parse(current) as CallingAnswers; const allAnswered = !( (res.canPonHouse != null && currentAnswers.pon == null) || (res.canCiiHouse != null && currentAnswers.cii == null) || @@ -445,7 +445,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { ); if (allAnswered || (Date.now() - waitingStartedAt > CALL_AND_RON_ASKING_TIMEOUT_MS)) { console.log(allAnswered ? 'ask all answerd' : 'ask timeout'); - await this.redisClient.del(`mahjong:gameCallAndRonAsking:${room.id}`); + await this.redisClient.del(`mahjong:gameCallingAsking:${room.id}`); clearInterval(interval); this.answer(room, engine, currentAnswers); return; @@ -511,7 +511,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async commit_kakan(roomId: MiMahjongGame['id'], user: MiUser) { + public async commit_kakan(roomId: MiMahjongGame['id'], user: MiUser, tile: string) { const room = await this.getRoom(roomId); if (room == null) return; if (room.gameState == null) return; @@ -521,7 +521,7 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { await this.clearTurnWaitingTimer(room.id); - const res = engine.commit_kakan(myHouse); + const res = engine.commit_kakan(myHouse, tile); this.globalEventService.publishMahjongRoomStream(room.id, 'kakanned', { }); } @@ -551,14 +551,14 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { const engine = new Mahjong.MasterGameEngine(room.gameState); const myHouse = getHouseOfUserId(room, engine, user.id); - // TODO: 自分にロン回答する権利がある状態かバリデーション + // TODO: 自分に回答する権利がある状態かバリデーション // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 - const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`); + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); if (current == null) throw new Error('no asking found'); - const currentAnswers = JSON.parse(current) as CallAndRonAnswers; + const currentAnswers = JSON.parse(current) as CallingAnswers; currentAnswers.ron[myHouse] = true; - await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers)); + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); } @bindThis @@ -567,14 +567,46 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { if (room == null) return; if (room.gameState == null) return; - // TODO: 自分にポン回答する権利がある状態かバリデーション + // TODO: 自分に回答する権利がある状態かバリデーション // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 - const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`); + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); if (current == null) throw new Error('no asking found'); - const currentAnswers = JSON.parse(current) as CallAndRonAnswers; + const currentAnswers = JSON.parse(current) as CallingAnswers; currentAnswers.pon = true; - await this.redisClient.set(`mahjong:gameCallAndRonAsking:${room.id}`, JSON.stringify(currentAnswers)); + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); + } + + @bindThis + public async commit_kan(roomId: MiMahjongGame['id'], user: MiUser) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + // TODO: 自分に回答する権利がある状態かバリデーション + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallingAnswers; + currentAnswers.kan = true; + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); + } + + @bindThis + public async commit_cii(roomId: MiMahjongGame['id'], user: MiUser, tiles: [Mahjong.Tile, Mahjong.Tile, Mahjong.Tile]) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + // TODO: 自分に回答する権利がある状態かバリデーション + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallingAnswers; + currentAnswers.cii = tiles; + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); } @bindThis @@ -587,14 +619,14 @@ export class MahjongService implements OnApplicationShutdown, OnModuleInit { const myHouse = getHouseOfUserId(room, engine, user.id); // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 - const current = await this.redisClient.get(`mahjong:gameCallAndRonAsking:${room.id}`); + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); if (current == null) throw new Error('no asking found'); - const currentAnswers = JSON.parse(current) as CallAndRonAnswers; + const currentAnswers = JSON.parse(current) as CallingAnswers; 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)); + await this.redisClient.set(`mahjong:gameCallingAsking:${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 23e5b3a3d4..53363bd5f3 100644 --- a/packages/backend/src/server/api/stream/channels/mahjong-room.ts +++ b/packages/backend/src/server/api/stream/channels/mahjong-room.ts @@ -56,6 +56,10 @@ class MahjongRoomChannel extends Channel { case 'tsumoHora': this.tsumoHora(); break; case 'ronHora': this.ronHora(); break; case 'pon': this.pon(); break; + case 'cii': this.cii(body.tiles); break; + case 'kan': this.kan(); break; + case 'ankan': this.ankan(body.tile); break; + case 'kakan': this.kakan(body.tile); break; case 'nop': this.nop(); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break; } @@ -117,6 +121,34 @@ class MahjongRoomChannel extends Channel { this.mahjongService.commit_pon(this.roomId!, this.user); } + @bindThis + private async cii(tiles: string[]) { + if (this.user == null) return; + + this.mahjongService.commit_cii(this.roomId!, this.user, tiles); + } + +@bindThis + private async kan() { + if (this.user == null) return; + + this.mahjongService.commit_kan(this.roomId!, this.user); + } + + @bindThis +private async ankan(tile: string) { + if (this.user == null) return; + + this.mahjongService.commit_ankan(this.roomId!, this.user, tile); +} + + @bindThis + private async kakan(tile: string) { + if (this.user == null) return; + + this.mahjongService.commit_kakan(this.roomId!, this.user, tile); + } + @bindThis private async nop() { if (this.user == null) return; diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue index 75e8fd5bbc..dec7add7b2 100644 --- a/packages/frontend/src/pages/mahjong/room.game.vue +++ b/packages/frontend/src/pages/mahjong/room.game.vue @@ -187,9 +187,13 @@ SPDX-License-Identifier: AGPL-3.0-only
- Ron - Pon - Skip + Ron + Pon + Cii + Kan + Skip + Ankan + Kakan Tsumo Riichi
@@ -197,7 +201,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-
{{ house === 'e' ? i18n.ts._mahjong.east : house === 's' ? i18n.ts._mahjong.south : house === 'w' ? i18n.ts._mahjong.west : i18n.ts._mahjong.north }}
+
+
{{ house === 'e' ? i18n.ts._mahjong.east : house === 's' ? i18n.ts._mahjong.south : house === 'w' ? i18n.ts._mahjong.west : i18n.ts._mahjong.north }}
+ + +
{{ i18n.ts._mahjong._yakus[yaku.name] }} {{ yaku.fan }}{{ i18n.ts._mahjong.fan }}
@@ -333,13 +345,38 @@ if (!props.room.isEnded) { } */ +function houseToUser(house: Mahjong.House) { + return room.value.gameState.user1House === house ? room.value.user1 : room.value.gameState.user2House === house ? room.value.user2 : room.value.gameState.user3House === house ? room.value.user3 : room.value.user4; +} + let riichiSelect = false; +let ankanSelect = false; +let kakanSelect = false; +let ciiSelect = false; function chooseTile(tile: Mahjong.Tile, ev: MouseEvent) { if (!isMyTurn.value) return; iTsumoed.value = false; + if (ankanSelect) { + props.connection!.send('ankan', { + tile: tile, + }); + ankanSelect = false; + selectableTiles.value = null; + return; + } else if (kakanSelect) { + props.connection!.send('kakan', { + tile: tile, + }); + kakanSelect = false; + selectableTiles.value = null; + return; + } else if (ciiSelect) { + return; + } + props.connection!.send('dahai', { tile: tile, riichi: riichiSelect, @@ -357,11 +394,18 @@ function riichi() { console.log(Mahjong.getTilesForRiichi(engine.value.myHandTiles)); } +function ankan() { + if (!isMyTurn.value) return; + + ankanSelect = true; + selectableTiles.value = engine.value.getAnkanableTiles(); +} + function kakan() { if (!isMyTurn.value) return; - props.connection!.send('kakan', { - }); + kakanSelect = true; + selectableTiles.value = engine.value.getKakanableTiles(); } function tsumoHora() { @@ -381,6 +425,16 @@ function pon() { }); } +function cii() { + props.connection!.send('cii', { + }); +} + +function kan() { + props.connection!.send('kan', { + }); +} + function skip() { engine.value.commit_nop(engine.value.myHouse); triggerRef(engine); @@ -649,6 +703,7 @@ onUnmounted(() => { height: 100%; max-width: 800px; margin: auto; + padding: 30px; box-sizing: border-box; background: #0009; color: #fff; diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts index 452918bb08..11992d877e 100644 --- a/packages/misskey-mahjong/src/engine.master.ts +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -310,34 +310,21 @@ export class MasterGameEngine { 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; + 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... - //} + let canCiiHouse: House | null = null; + switch (house) { + case 'e': canCiiHouse = this.canCii('s', house, tile) ? 's' : this.canCii('w', house, tile) ? 'w' : this.canCii('n', house, tile) ? 'n' : null; break; + case 's': canCiiHouse = this.canCii('e', house, tile) ? 'e' : this.canCii('w', house, tile) ? 'w' : this.canCii('n', house, tile) ? 'n' : null; break; + case 'w': canCiiHouse = this.canCii('e', house, tile) ? 'e' : this.canCii('s', house, tile) ? 's' : this.canCii('n', house, tile) ? 'n' : null; break; + case 'n': canCiiHouse = this.canCii('e', house, tile) ? 'e' : this.canCii('s', house, tile) ? 's' : this.canCii('w', house, tile) ? 'w' : null; break; + } - if (canRonHouses.length > 0 || canPonHouse != null) { + if (canRonHouses.length > 0 || canPonHouse != null || canCiiHouse != null) { if (canRonHouses.length > 0) { this.state.ronAsking = { callee: house, @@ -447,9 +434,9 @@ export class MasterGameEngine { }; } - public commit_resolveCallAndRonInterruption(answers: { + public commit_resolveCallingInterruption(answers: { pon: boolean; - cii: false | [Tile, Tile]; + cii: false | [Tile, Tile, Tile]; kan: boolean; ron: House[]; }) { @@ -484,6 +471,7 @@ export class MasterGameEngine { const rinsyan = this.tsumo(); this.state.turn = kan.caller; + return { type: 'kanned' as const, caller: kan.caller, @@ -499,6 +487,7 @@ export class MasterGameEngine { this.state.huros[pon.caller].push({ type: 'pon', tile, from: pon.callee }); this.state.turn = pon.caller; + return { type: 'ponned' as const, caller: pon.caller, @@ -513,6 +502,7 @@ export class MasterGameEngine { this.state.huros[cii.caller].push({ type: 'cii', tiles: [tile, answers.cii[0], answers.cii[1]], from: cii.callee }); this.state.turn = cii.caller; + return { type: 'ciied' as const, caller: cii.caller, diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts index 578555ffd4..40b45b3599 100644 --- a/packages/misskey-mahjong/src/engine.player.ts +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -55,13 +55,10 @@ export type PlayerState = { }; 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; + canPon: { callee: House } | null; + canCii: { callee: House } | null; + canKan: { callee: House } | null; // = 大明槓 + canRon: { callee: House } | null; }; export type KyokuResult = { @@ -138,11 +135,16 @@ export class PlayerGameEngine { } else { const canRon = Common.getHoraSets(this.myHandTiles.concat(tile)).length > 0; const canPon = this.myHandTiles.filter(t => t === tile).length === 2; + const canKan = this.myHandTiles.filter(t => t === tile).length === 3; + const canCii = house === Common.prevHouse(this.myHouse) && + Common.SHUNTU_PATTERNS.some(pattern => + pattern.includes(tile) && + pattern.filter(t => this.myHandTiles.includes(t)).length >= 2); - // TODO: canCii - - if (canRon) this.state.canRonSource = house; - if (canPon) this.state.canPonSource = house; + if (canRon) this.state.canRon = { callee: house }; + if (canPon) this.state.canPon = { callee: house }; + if (canKan) this.state.canKan = { callee: house }; + if (canCii) this.state.canCii = { callee: house }; } } @@ -193,7 +195,7 @@ export class PlayerGameEngine { }): Record { console.log('commit_ronHora', this.state.turn, callers, callee); - this.state.canRonSource = null; + this.state.canRon = null; const resultMap: Record = { e: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, @@ -236,7 +238,7 @@ export class PlayerGameEngine { * @param callee 牌を捨てた人 */ public commit_pon(caller: House, callee: House) { - this.state.canPonSource = null; + this.state.canPon = null; const lastTile = this.state.hoTiles[callee].pop(); if (lastTile == null) throw new PlayerGameEngine.InvalidOperationError(); @@ -253,8 +255,10 @@ export class PlayerGameEngine { } public commit_nop() { - this.state.canRonSource = null; - this.state.canPonSource = null; + this.state.canRon = null; + this.state.canPon = null; + this.state.canKan = null; + this.state.canCii = null; } public get isMenzen(): boolean { @@ -270,4 +274,22 @@ export class PlayerGameEngine { if (Common.getTilesForRiichi(this.myHandTiles).length === 0) return false; return true; } + + public canAnkan(): boolean { + if (this.state.turn !== this.myHouse) return false; + return this.myHandTiles.filter(t => this.myHandTiles.filter(tt => tt === t).length >= 4).length > 0; + } + + public canKakan(): boolean { + if (this.state.turn !== this.myHouse) return false; + return this.state.huros[this.myHouse].filter(h => h.type === 'pon' && this.myHandTiles.includes(h.tile)).length > 0; + } + + public getAnkanableTiles(): Tile[] { + return this.myHandTiles.filter(t => this.myHandTiles.filter(tt => tt === t).length >= 4); + } + + public getKakanableTiles(): Tile[] { + return this.state.huros[this.myHouse].filter(h => h.type === 'pon' && this.myHandTiles.includes(h.tile)).map(h => h.tile); + } }