diff --git a/locales/index.d.ts b/locales/index.d.ts index 910b1edad8..5e00e539f2 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 6460397db7..915b9a2080 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 5ddd100e6c..5b4c8cb44f 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 b2a4032d4b..f97f71eb43 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 77eaa6d1d3..df92137f51 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 360b75745c..9ca107278b 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 dbbeb20f42..0bdbfbcf54 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(() => {