From 6039f27bd50ef1fbbbe6bffe12b18614c9e5b85c Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 21 Jan 2024 12:05:51 +0900 Subject: [PATCH] enhance(reversi): tweak reversi --- locales/index.d.ts | 4 + locales/ja-JP.yml | 1 + .../backend/src/core/GlobalEventService.ts | 3 + packages/backend/src/core/ReversiService.ts | 175 ++++++++++-------- .../api/stream/channels/reversi-game.ts | 8 + .../src/pages/reversi/game.setting.vue | 17 +- packages/frontend/src/pages/reversi/game.vue | 19 ++ 7 files changed, 149 insertions(+), 78 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index 910b1edad..5e00e539f 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9553,6 +9553,10 @@ export interface Locale extends ILocale { * 対戦相手を探しています */ "lookingForPlayer": string; + /** + * 対局がキャンセルされました + */ + "gameCanceled": string; }; "_offlineScreen": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6460397db..915b9a208 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2544,6 +2544,7 @@ _reversi: timeLimitForEachTurn: "1ターンの時間制限" freeMatch: "フリーマッチ" lookingForPlayer: "対戦相手を探しています" + gameCanceled: "対局がキャンセルされました" _offlineScreen: title: "オフライン - サーバーに接続できません" diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 5ddd100e6..5b4c8cb44 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -188,6 +188,9 @@ export interface ReversiGameEventTypes { winnerId: MiUser['id'] | null; game: Packed<'ReversiGameDetailed'>; }; + canceled: { + userId: MiUser['id']; + }; } //#endregion diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index b2a4032d4..f97f71eb4 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -61,6 +61,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 3, JSON.stringify(game)); } + @bindThis + private async deleteGameCache(gameId: MiReversiGame['id']) { + await this.redisClient.del(`reversi:game:cache:${gameId}`); + } + @bindThis public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise { if (targetUser.id === me.id) { @@ -239,88 +244,93 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { if (isBothReady) { // 3秒後、両者readyならゲーム開始 setTimeout(async () => { - const freshGame = await this.reversiGamesRepository.findOneBy({ id: game.id }); + const freshGame = await this.get(game.id); if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; if (!freshGame.user1Ready || !freshGame.user2Ready) return; - let bw: number; - if (freshGame.bw === 'random') { - bw = Math.random() > 0.5 ? 1 : 2; - } else { - bw = parseInt(freshGame.bw, 10); - } - - function getRandomMap() { - const mapCount = Object.entries(Reversi.maps).length; - const rnd = Math.floor(Math.random() * mapCount); - return Object.values(Reversi.maps)[rnd].data; - } - - const map = freshGame.map != null ? freshGame.map : getRandomMap(); - - const crc32 = CRC32.str(JSON.stringify(freshGame.logs)).toString(); - - const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() - .set({ - startedAt: new Date(), - isStarted: true, - black: bw, - map: map, - crc32, - }) - .where('id = :id', { id: game.id }) - .returning('*') - .execute() - .then((response) => response.raw[0]); - this.cacheGame(updatedGame); - - //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 - const engine = new Reversi.Game(map, { - isLlotheo: freshGame.isLlotheo, - canPutEverywhere: freshGame.canPutEverywhere, - loopedBoard: freshGame.loopedBoard, - }); - - if (engine.isEnded) { - let winner; - if (engine.winner === true) { - winner = bw === 1 ? freshGame.user1Id : freshGame.user2Id; - } else if (engine.winner === false) { - winner = bw === 1 ? freshGame.user2Id : freshGame.user1Id; - } else { - winner = null; - } - - const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() - .set({ - isEnded: true, - endedAt: new Date(), - winnerId: winner, - }) - .where('id = :id', { id: game.id }) - .returning('*') - .execute() - .then((response) => response.raw[0]); - this.cacheGame(updatedGame); - - this.globalEventService.publishReversiGameStream(game.id, 'ended', { - winnerId: winner, - game: await this.reversiGameEntityService.packDetail(game.id), - }); - - return; - } - //#endregion - - this.redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, updatedGame.timeLimitForEachTurn, ''); - - this.globalEventService.publishReversiGameStream(game.id, 'started', { - game: await this.reversiGameEntityService.packDetail(game.id), - }); + this.startGame(freshGame); }, 3000); } } + @bindThis + private async startGame(game: MiReversiGame) { + let bw: number; + if (game.bw === 'random') { + bw = Math.random() > 0.5 ? 1 : 2; + } else { + bw = parseInt(game.bw, 10); + } + + function getRandomMap() { + const mapCount = Object.entries(Reversi.maps).length; + const rnd = Math.floor(Math.random() * mapCount); + return Object.values(Reversi.maps)[rnd].data; + } + + const map = game.map != null ? game.map : getRandomMap(); + + const crc32 = CRC32.str(JSON.stringify(game.logs)).toString(); + + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + startedAt: new Date(), + isStarted: true, + black: bw, + map: map, + crc32, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); + + //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 + const engine = new Reversi.Game(map, { + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard, + }); + + if (engine.isEnded) { + let winner; + if (engine.winner === true) { + winner = bw === 1 ? game.user1Id : game.user2Id; + } else if (engine.winner === false) { + winner = bw === 1 ? game.user2Id : game.user1Id; + } else { + winner = null; + } + + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + isEnded: true, + endedAt: new Date(), + winnerId: winner, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); + + this.globalEventService.publishReversiGameStream(game.id, 'ended', { + winnerId: winner, + game: await this.reversiGameEntityService.packDetail(game.id), + }); + + return; + } + //#endregion + + this.redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, updatedGame.timeLimitForEachTurn, ''); + + this.globalEventService.publishReversiGameStream(game.id, 'started', { + game: await this.reversiGameEntityService.packDetail(game.id), + }); + } + @bindThis public async getInvitations(user: MiUser): Promise { const invitations = await this.redisClient.zrange( @@ -510,6 +520,21 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } } + @bindThis + public async cancelGame(gameId: MiReversiGame['id'], user: MiUser) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); + if (game.isStarted) return; + if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return; + + await this.reversiGamesRepository.delete(game.id); + this.deleteGameCache(game.id); + + this.globalEventService.publishReversiGameStream(game.id, 'canceled', { + userId: user.id, + }); + } + @bindThis public async get(id: MiReversiGame['id']): Promise { const cached = await this.redisClient.get(`reversi:game:cache:${id}`); diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 77eaa6d1d..df92137f5 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -40,6 +40,7 @@ class ReversiGameChannel extends Channel { switch (type) { case 'ready': this.ready(body); break; case 'updateSettings': this.updateSettings(body.key, body.value); break; + case 'cancel': this.cancelGame(); break; case 'putStone': this.putStone(body.pos, body.id); break; case 'checkState': this.checkState(body.crc32); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break; @@ -60,6 +61,13 @@ class ReversiGameChannel extends Channel { this.reversiService.gameReady(this.gameId!, this.user, ready); } + @bindThis + private async cancelGame() { + if (this.user == null) return; + + this.reversiService.cancelGame(this.gameId!, this.user); + } + @bindThis private async putStone(pos: number, id: string) { if (this.user == null) return; diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 360b75745..9ca107278 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -86,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts.cancel }} + {{ i18n.ts.cancel }} {{ i18n.ts._reversi.ready }} {{ i18n.ts._reversi.cancelReady }}
@@ -109,9 +109,12 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; import { MenuItem } from '@/types/menu.js'; +import { useRouter } from '@/global/router/supplier.js'; const $i = signinRequired(); +const router = useRouter(); + const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category))); const props = defineProps<{ @@ -171,8 +174,16 @@ function chooseMap(ev: MouseEvent) { os.popupMenu(menu, ev.currentTarget ?? ev.target); } -function exit() { - props.connection.send('exit', {}); +async function cancel() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + + props.connection.send('cancel', {}); + + router.push('/reversi'); } function ready() { diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index dbbeb20f4..0bdbfbcf5 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -17,6 +17,14 @@ import GameBoard from './game.board.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { useStream } from '@/stream.js'; +import { signinRequired } from '@/account.js'; +import { useRouter } from '@/global/router/supplier.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; + +const $i = signinRequired(); + +const router = useRouter(); const props = defineProps<{ gameId: string; @@ -45,6 +53,17 @@ async function fetchGame() { connection.value.on('started', x => { game.value = x.game; }); + connection.value.on('canceled', x => { + connection.value?.dispose(); + + if (x.userId !== $i.id) { + os.alert({ + type: 'warning', + text: i18n.ts._reversi.gameCanceled, + }); + router.push('/reversi'); + } + }); } onMounted(() => {