From 6039f27bd50ef1fbbbe6bffe12b18614c9e5b85c Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
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 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<MiReversiGame | null> {
 		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<MiUser['id'][]> {
 		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<MiReversiGame | null> {
 		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
 					<template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
 				</div>
 				<div class="_buttonsCenter">
-					<MkButton rounded danger @click="exit">{{ i18n.ts.cancel }}</MkButton>
+					<MkButton rounded danger @click="cancel">{{ i18n.ts.cancel }}</MkButton>
 					<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
 					<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
 				</div>
@@ -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(() => {
-- 
GitLab