From 259992c65f008c3df474970f087aba9716d3465c Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Mon, 22 Jan 2024 12:03:32 +0900
Subject: [PATCH] enhance(reversi): some tweaks

---
 .../api/stream/channels/reversi-game.ts       |  8 +-
 .../frontend/src/pages/reversi/game.board.vue | 98 ++++++++++++-------
 packages/frontend/src/pages/reversi/game.vue  | 38 +++----
 3 files changed, 83 insertions(+), 61 deletions(-)

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 df92137f5..820c80006 100644
--- a/packages/backend/src/server/api/stream/channels/reversi-game.ts
+++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts
@@ -42,7 +42,7 @@ class ReversiGameChannel extends Channel {
 			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 'resync': this.resync(body.crc32); break;
 			case 'claimTimeIsUp': this.claimTimeIsUp(); break;
 		}
 	}
@@ -76,12 +76,10 @@ class ReversiGameChannel extends Channel {
 	}
 
 	@bindThis
-	private async checkState(crc32: string | number) {
-		if (crc32 != null) return;
-
+	private async resync(crc32: string | number) {
 		const game = await this.reversiService.checkCrc(this.gameId!, crc32);
 		if (game) {
-			this.send('rescue', game);
+			this.send('resynced', game);
 		}
 	}
 
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index 4d4450ed7..d492296c1 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -163,7 +163,7 @@ const $i = signinRequired();
 
 const props = defineProps<{
 	game: Misskey.entities.ReversiGameDetailed;
-	connection: Misskey.ChannelConnection;
+	connection?: Misskey.ChannelConnection | null;
 }>();
 
 const showBoardLabels = ref<boolean>(false);
@@ -240,10 +240,10 @@ watch(logPos, (v) => {
 
 if (game.value.isStarted && !game.value.isEnded) {
 	useInterval(() => {
-		if (game.value.isEnded) return;
+		if (game.value.isEnded || props.connection == null) return;
 		const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
 		if (_DEV_) console.log('crc32', crc32);
-		props.connection.send('checkState', {
+		props.connection.send('resync', {
 			crc32: crc32,
 		});
 	}, 10000, { immediate: false, afterMounted: true });
@@ -267,7 +267,7 @@ function putStone(pos) {
 	});
 
 	const id = Math.random().toString(36).slice(2);
-	props.connection.send('putStone', {
+	props.connection!.send('putStone', {
 		pos: pos,
 		id,
 	});
@@ -283,22 +283,24 @@ const myTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
 const opTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn);
 
 const TIMER_INTERVAL_SEC = 3;
-useInterval(() => {
-	if (myTurnTimerRmain.value > 0) {
-		myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC);
-	}
-	if (opTurnTimerRmain.value > 0) {
-		opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC);
-	}
-
-	if (iAmPlayer.value) {
-		if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) {
-			props.connection.send('claimTimeIsUp', {});
+if (!props.game.isEnded) {
+	useInterval(() => {
+		if (myTurnTimerRmain.value > 0) {
+			myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC);
+		}
+		if (opTurnTimerRmain.value > 0) {
+			opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC);
 		}
-	}
-}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
 
-function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
+		if (iAmPlayer.value) {
+			if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) {
+			props.connection!.send('claimTimeIsUp', {});
+			}
+		}
+	}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
+}
+
+async function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
 	game.value.logs = Reversi.Serializer.serializeLogs([
 		...Reversi.Serializer.deserializeLogs(game.value.logs),
 		log,
@@ -309,17 +311,25 @@ function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
 	if (log.id == null || !appliedOps.includes(log.id)) {
 		switch (log.operation) {
 			case 'put': {
+				sound.playUrl('/client-assets/reversi/put.mp3', {
+					volume: 1,
+					playbackRate: 1,
+				});
+
+				if (log.player !== engine.value.turn) { // = desyncが発生している
+					const _game = await misskeyApi('reversi/show-game', {
+						gameId: props.game.id,
+					});
+					restoreGame(_game);
+					return;
+				}
+
 				engine.value.putStone(log.pos);
 				triggerRef(engine);
 
 				myTurnTimerRmain.value = game.value.timeLimitForEachTurn;
 				opTurnTimerRmain.value = game.value.timeLimitForEachTurn;
 
-				sound.playUrl('/client-assets/reversi/put.mp3', {
-					volume: 1,
-					playbackRate: 1,
-				});
-
 				checkEnd();
 				break;
 			}
@@ -366,9 +376,7 @@ function checkEnd() {
 	}
 }
 
-function onStreamRescue(_game) {
-	console.log('rescue');
-
+function restoreGame(_game) {
 	game.value = deepClone(_game);
 
 	engine.value = Reversi.Serializer.restoreGame({
@@ -384,6 +392,12 @@ function onStreamRescue(_game) {
 	checkEnd();
 }
 
+function onStreamResynced(_game) {
+	console.log('resynced');
+
+	restoreGame(_game);
+}
+
 async function surrender() {
 	const { canceled } = await os.confirm({
 		type: 'warning',
@@ -434,27 +448,35 @@ function share() {
 }
 
 onMounted(() => {
-	props.connection.on('log', onStreamLog);
-	props.connection.on('rescue', onStreamRescue);
-	props.connection.on('ended', onStreamEnded);
+	if (props.connection != null) {
+		props.connection.on('log', onStreamLog);
+		props.connection.on('resynced', onStreamResynced);
+		props.connection.on('ended', onStreamEnded);
+	}
 });
 
 onActivated(() => {
-	props.connection.on('log', onStreamLog);
-	props.connection.on('rescue', onStreamRescue);
-	props.connection.on('ended', onStreamEnded);
+	if (props.connection != null) {
+		props.connection.on('log', onStreamLog);
+		props.connection.on('resynced', onStreamResynced);
+		props.connection.on('ended', onStreamEnded);
+	}
 });
 
 onDeactivated(() => {
-	props.connection.off('log', onStreamLog);
-	props.connection.off('rescue', onStreamRescue);
-	props.connection.off('ended', onStreamEnded);
+	if (props.connection != null) {
+		props.connection.off('log', onStreamLog);
+		props.connection.off('resynced', onStreamResynced);
+		props.connection.off('ended', onStreamEnded);
+	}
 });
 
 onUnmounted(() => {
-	props.connection.off('log', onStreamLog);
-	props.connection.off('rescue', onStreamRescue);
-	props.connection.off('ended', onStreamEnded);
+	if (props.connection != null) {
+		props.connection.off('log', onStreamLog);
+		props.connection.off('resynced', onStreamResynced);
+		props.connection.off('ended', onStreamEnded);
+	}
 });
 </script>
 
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
index 0bdbfbcf5..d1e410391 100644
--- a/packages/frontend/src/pages/reversi/game.vue
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div v-if="game == null || connection == null"><MkLoading/></div>
-<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection"/>
+<div v-if="game == null || (!game.isEnded && connection == null)"><MkLoading/></div>
+<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection!"/>
 <GameBoard v-else :game="game" :connection="connection"/>
 </template>
 
@@ -47,23 +47,25 @@ async function fetchGame() {
 	if (connection.value) {
 		connection.value.dispose();
 	}
-	connection.value = useStream().useChannel('reversiGame', {
-		gameId: game.value.id,
-	});
-	connection.value.on('started', x => {
-		game.value = x.game;
-	});
-	connection.value.on('canceled', x => {
-		connection.value?.dispose();
+	if (!game.value.isEnded) {
+		connection.value = useStream().useChannel('reversiGame', {
+			gameId: game.value.id,
+		});
+		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');
-		}
-	});
+			if (x.userId !== $i.id) {
+				os.alert({
+					type: 'warning',
+					text: i18n.ts._reversi.gameCanceled,
+				});
+				router.push('/reversi');
+			}
+		});
+	}
 }
 
 onMounted(() => {