From b9a81edae5bfbd2a5b0d03e9b523a04ea5cf4bc5 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 20 Jan 2024 13:14:46 +0900
Subject: [PATCH] enhance(reversi): tweak reversi

---
 .../backend/src/core/GlobalEventService.ts    |   8 +-
 packages/backend/src/core/ReversiService.ts   |  51 ++++----
 .../core/entities/ReversiGameEntityService.ts |   6 +-
 packages/backend/src/models/ReversiGame.ts    |   9 +-
 .../src/models/json-schema/reversi-game.ts    |  16 +--
 .../api/stream/channels/reversi-game.ts       |   6 +-
 .../frontend/src/pages/reversi/game.board.vue | 110 ++++++++++--------
 .../misskey-js/src/autogen/apiClientJSDoc.ts  |   2 +-
 packages/misskey-js/src/autogen/endpoint.ts   |   2 +-
 packages/misskey-js/src/autogen/entities.ts   |   2 +-
 packages/misskey-js/src/autogen/models.ts     |   2 +-
 packages/misskey-js/src/autogen/types.ts      |   8 +-
 packages/misskey-reversi/package.json         |  19 ++-
 packages/misskey-reversi/src/game.ts          |   7 +-
 packages/misskey-reversi/src/index.ts         |   8 +-
 packages/misskey-reversi/src/serializer.ts    |  98 ++++++++++++++++
 16 files changed, 224 insertions(+), 130 deletions(-)
 create mode 100644 packages/misskey-reversi/src/serializer.ts

diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 11a8935be2..896149f238 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -5,6 +5,7 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import * as Redis from 'ioredis';
+import * as Reversi from 'misskey-reversi';
 import type { MiChannel } from '@/models/Channel.js';
 import type { MiUser } from '@/models/User.js';
 import type { MiUserProfile } from '@/models/UserProfile.js';
@@ -179,12 +180,7 @@ export interface ReversiGameEventTypes {
 		key: string;
 		value: any;
 	};
-	putStone: {
-		at: number;
-		color: boolean;
-		pos: number;
-		next: boolean;
-	};
+	log: Reversi.Serializer.Log & { id: string | null };
 	syncState: {
 		crc32: string;
 	};
diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts
index 6e80261330..9fe7255e48 100644
--- a/packages/backend/src/core/ReversiService.ts
+++ b/packages/backend/src/core/ReversiService.ts
@@ -235,11 +235,14 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
 
 				const map = freshGame.map != null ? freshGame.map : getRandomMap();
 
+				const crc32 = CRC32.str(JSON.stringify(freshGame.logs)).toString();
+
 				await this.reversiGamesRepository.update(game.id, {
 					startedAt: new Date(),
 					isStarted: true,
 					black: bw,
 					map: map,
+					crc32,
 				});
 
 				//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
@@ -309,7 +312,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
 	}
 
 	@bindThis
-	public async putStoneToGame(game: MiReversiGame, user: MiUser, pos: number) {
+	public async putStoneToGame(game: MiReversiGame, user: MiUser, pos: number, id?: string | null) {
 		if (!game.isStarted) return;
 		if (game.isEnded) return;
 		if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
@@ -319,56 +322,58 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
 				? true
 				: false;
 
-		const o = new Reversi.Game(game.map, {
+		const engine = Reversi.Serializer.restoreGame({
+			map: game.map,
 			isLlotheo: game.isLlotheo,
 			canPutEverywhere: game.canPutEverywhere,
 			loopedBoard: game.loopedBoard,
+			logs: game.logs,
 		});
 
-		// 盤面の状態を再生
-		for (const log of game.logs) {
-			o.put(log.color, log.pos);
-		}
-
-		if (o.turn !== myColor) return;
+		if (engine.turn !== myColor) return;
+		if (!engine.canPut(myColor, pos)) return;
 
-		if (!o.canPut(myColor, pos)) return;
-		o.put(myColor, pos);
+		engine.putStone(pos);
 
 		let winner;
-		if (o.isEnded) {
-			if (o.winner === true) {
+		if (engine.isEnded) {
+			if (engine.winner === true) {
 				winner = game.black === 1 ? game.user1Id : game.user2Id;
-			} else if (o.winner === false) {
+			} else if (engine.winner === false) {
 				winner = game.black === 1 ? game.user2Id : game.user1Id;
 			} else {
 				winner = null;
 			}
 		}
 
+		const logs = Reversi.Serializer.deserializeLogs(game.logs);
+
 		const log = {
-			at: Date.now(),
-			color: myColor,
+			time: Date.now(),
+			player: myColor,
+			operation: 'put',
 			pos,
-		};
+		} as const;
+
+		logs.push(log);
 
-		const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()).toString();
+		const serializeLogs = Reversi.Serializer.serializeLogs(logs);
 
-		game.logs.push(log);
+		const crc32 = CRC32.str(JSON.stringify(serializeLogs)).toString();
 
 		await this.reversiGamesRepository.update(game.id, {
 			crc32,
-			isEnded: o.isEnded,
+			isEnded: engine.isEnded,
 			winnerId: winner,
-			logs: game.logs,
+			logs: serializeLogs,
 		});
 
-		this.globalEventService.publishReversiGameStream(game.id, 'putStone', {
+		this.globalEventService.publishReversiGameStream(game.id, 'log', {
 			...log,
-			next: o.turn,
+			id: id ?? null,
 		});
 
-		if (o.isEnded) {
+		if (engine.isEnded) {
 			this.globalEventService.publishReversiGameStream(game.id, 'ended', {
 				winnerId: winner ?? null,
 				game: await this.reversiGameEntityService.packDetail(game.id, user),
diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts
index 8d95204928..a7adc681f6 100644
--- a/packages/backend/src/core/entities/ReversiGameEntityService.ts
+++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts
@@ -55,11 +55,7 @@ export class ReversiGameEntityService {
 			isLlotheo: game.isLlotheo,
 			canPutEverywhere: game.canPutEverywhere,
 			loopedBoard: game.loopedBoard,
-			logs: game.logs.map(log => ({
-				at: log.at,
-				color: log.color,
-				pos: log.pos,
-			})),
+			logs: game.logs,
 			map: game.map,
 		});
 	}
diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts
index d297d1f01d..dcaa5c9fa9 100644
--- a/packages/backend/src/models/ReversiGame.ts
+++ b/packages/backend/src/models/ReversiGame.ts
@@ -76,11 +76,7 @@ export class MiReversiGame {
 	@Column('jsonb', {
 		default: [],
 	})
-	public logs: {
-		at: number;
-		color: boolean;
-		pos: number;
-	}[];
+	public logs: number[][];
 
 	@Column('varchar', {
 		array: true, length: 64,
@@ -117,9 +113,6 @@ export class MiReversiGame {
 	})
 	public form2: any | null;
 
-	/**
-	 * ログのposを文字列としてすべて連結したもののCRC32値
-	 */
 	@Column('varchar', {
 		length: 32, nullable: true,
 	})
diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts
index 0d23b9dc79..b94046438b 100644
--- a/packages/backend/src/models/json-schema/reversi-game.ts
+++ b/packages/backend/src/models/json-schema/reversi-game.ts
@@ -204,22 +204,8 @@ export const packedReversiGameDetailedSchema = {
 			type: 'array',
 			optional: false, nullable: false,
 			items: {
-				type: 'object',
+				type: 'array',
 				optional: false, nullable: false,
-				properties: {
-					at: {
-						type: 'number',
-						optional: false, nullable: false,
-					},
-					color: {
-						type: 'boolean',
-						optional: false, nullable: false,
-					},
-					pos: {
-						type: 'number',
-						optional: false, nullable: false,
-					},
-				},
 			},
 		},
 		map: {
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 c67c05fb09..2d8c396db9 100644
--- a/packages/backend/src/server/api/stream/channels/reversi-game.ts
+++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts
@@ -45,7 +45,7 @@ class ReversiGameChannel extends Channel {
 		switch (type) {
 			case 'ready': this.ready(body); break;
 			case 'updateSettings': this.updateSettings(body.key, body.value); break;
-			case 'putStone': this.putStone(body.pos); break;
+			case 'putStone': this.putStone(body.pos, body.id); break;
 			case 'syncState': this.syncState(body.crc32); break;
 		}
 	}
@@ -72,14 +72,14 @@ class ReversiGameChannel extends Channel {
 	}
 
 	@bindThis
-	private async putStone(pos: number) {
+	private async putStone(pos: number, id: string) {
 		if (this.user == null) return;
 
 		// TODO: キャッシュしたい
 		const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! });
 		if (game == null) throw new Error('game not found');
 
-		this.reversiService.putStoneToGame(game, this.user, pos);
+		this.reversiService.putStoneToGame(game, this.user, pos, id);
 	}
 
 	@bindThis
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index 582967ad2b..bf45fc4119 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -13,12 +13,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<Mfm :key="'turn:' + turnUser.id" :text="i18n.tsx._reversi.turnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
 				<MkEllipsis/>
 			</div>
-			<div v-if="(logPos !== logs.length) && turnUser" class="turn">
+			<div v-if="(logPos !== game.logs.length) && turnUser" class="turn">
 				<Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.tsx._reversi.pastTurnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
 			</div>
 			<div v-if="iAmPlayer && !game.isEnded && !isMyTurn" class="turn1">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/></div>
 			<div v-if="iAmPlayer && !game.isEnded && isMyTurn" class="turn2" style="animation: tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</div>
-			<div v-if="game.isEnded && logPos == logs.length" class="result">
+			<div v-if="game.isEnded && logPos == game.logs.length" class="result">
 				<template v-if="game.winner">
 					<Mfm :key="'won'" :text="i18n.tsx._reversi.won({ name: game.winner.name ?? game.winner.username })" :plain="true" :customEmojis="game.winner.emojis"/>
 					<span v-if="game.surrendered != null"> ({{ i18n.ts._reversi.surrendered }})</span>
@@ -69,12 +69,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</div>
 
 		<div v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;">
-			<div>{{ logPos }} / {{ logs.length }}</div>
+			<div>{{ logPos }} / {{ game.logs.length }}</div>
 			<div v-if="!autoplaying" class="_buttonsCenter">
 				<MkButton :disabled="logPos === 0" @click="logPos = 0"><i class="ti ti-chevrons-left"></i></MkButton>
 				<MkButton :disabled="logPos === 0" @click="logPos--"><i class="ti ti-chevron-left"></i></MkButton>
-				<MkButton :disabled="logPos === logs.length" @click="logPos++"><i class="ti ti-chevron-right"></i></MkButton>
-				<MkButton :disabled="logPos === logs.length" @click="logPos = logs.length"><i class="ti ti-chevrons-right"></i></MkButton>
+				<MkButton :disabled="logPos === game.logs.length" @click="logPos++"><i class="ti ti-chevron-right"></i></MkButton>
+				<MkButton :disabled="logPos === game.logs.length" @click="logPos = game.logs.length"><i class="ti ti-chevrons-right"></i></MkButton>
 			</div>
 			<MkButton style="margin: auto;" :disabled="autoplaying" @click="autoplay()"><i class="ti ti-player-play"></i></MkButton>
 		</div>
@@ -115,18 +115,15 @@ const props = defineProps<{
 const showBoardLabels = true;
 const autoplaying = ref<boolean>(false);
 const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
-const logs = ref<Misskey.entities.ReversiLog[]>(game.value.logs);
-const logPos = ref<number>(logs.value.length);
-const engine = shallowRef<Reversi.Game>(new Reversi.Game(game.value.map, {
+const logPos = ref<number>(game.value.logs.length);
+const engine = shallowRef<Reversi.Game>(Reversi.Serializer.restoreGame({
+	map: game.value.map,
 	isLlotheo: game.value.isLlotheo,
 	canPutEverywhere: game.value.canPutEverywhere,
 	loopedBoard: game.value.loopedBoard,
+	logs: game.value.logs,
 }));
 
-for (const log of game.value.logs) {
-	engine.value.put(log.color, log.pos);
-}
-
 const iAmPlayer = computed(() => {
 	return game.value.user1Id === $i.id || game.value.user2Id === $i.id;
 });
@@ -177,60 +174,76 @@ const cellsStyle = computed(() => {
 
 watch(logPos, (v) => {
 	if (!game.value.isEnded) return;
-	const _o = new Reversi.Game(game.value.map, {
+	engine.value = Reversi.Serializer.restoreGame({
+		map: game.value.map,
 		isLlotheo: game.value.isLlotheo,
 		canPutEverywhere: game.value.canPutEverywhere,
 		loopedBoard: game.value.loopedBoard,
+		logs: game.value.logs.slice(0, v),
 	});
-	for (const log of logs.value.slice(0, v)) {
-		_o.put(log.color, log.pos);
-	}
-	engine.value = _o;
 });
 
 if (game.value.isStarted && !game.value.isEnded) {
 	useInterval(() => {
 		if (game.value.isEnded) return;
-		const crc32 = CRC32.str(logs.value.map(x => x.pos.toString()).join(''));
+		const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
+		if (_DEV_) console.log('crc32', crc32);
 		props.connection.send('syncState', {
 			crc32: crc32,
 		});
-	}, 5000, { immediate: false, afterMounted: true });
+	}, 10000, { immediate: false, afterMounted: true });
 }
 
+const appliedOps: string[] = [];
+
 function putStone(pos) {
 	if (game.value.isEnded) return;
 	if (!iAmPlayer.value) return;
 	if (!isMyTurn.value) return;
 	if (!engine.value.canPut(myColor.value!, pos)) return;
 
-	engine.value.put(myColor.value!, pos);
+	engine.value.putStone(pos);
+
 	triggerRef(engine);
 
 	// サウンドを再生する
 	//sound.play(myColor.value ? 'reversiPutBlack' : 'reversiPutWhite');
 
+	const id = Math.random().toString(36).slice(2);
 	props.connection.send('putStone', {
 		pos: pos,
+		id,
 	});
+	appliedOps.push(id);
 
 	checkEnd();
 }
 
-function onPutStone(x) {
-	logs.value.push(x);
+function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
+	game.value.logs = Reversi.Serializer.serializeLogs([
+		...Reversi.Serializer.deserializeLogs(game.value.logs),
+		log,
+	]);
+
 	logPos.value++;
-	engine.value.put(x.color, x.pos);
-	triggerRef(engine);
-	checkEnd();
 
-	// サウンドを再生する
-	if (x.color !== myColor.value) {
-		//sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
+	if (log.id == null || !appliedOps.includes(log.id)) {
+		switch (log.operation) {
+			case 'put': {
+				engine.value.putStone(log.pos);
+				triggerRef(engine);
+				checkEnd();
+				//sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
+				break;
+			}
+
+			default:
+				break;
+		}
 	}
 }
 
-function onEnded(x) {
+function onStreamEnded(x) {
 	game.value = deepClone(x.game);
 }
 
@@ -250,23 +263,20 @@ function checkEnd() {
 	}
 }
 
-function onRescue(_game) {
+function onStreamRescue(_game) {
+	console.log('rescue');
+
 	game.value = deepClone(_game);
 
-	engine.value = new Reversi.Game(game.value.map, {
+	engine.value = Reversi.Serializer.restoreGame({
+		map: game.value.map,
 		isLlotheo: game.value.isLlotheo,
 		canPutEverywhere: game.value.canPutEverywhere,
 		loopedBoard: game.value.loopedBoard,
+		logs: game.value.logs,
 	});
 
-	for (const log of game.value.logs) {
-		engine.value.put(log.color, log.pos);
-	}
-
-	triggerRef(engine);
-
-	logs.value = game.value.logs;
-	logPos.value = logs.value.length;
+	logPos.value = game.value.logs.length;
 
 	checkEnd();
 }
@@ -280,21 +290,22 @@ function surrender() {
 function autoplay() {
 	autoplaying.value = true;
 	logPos.value = 0;
+	const logs = Reversi.Serializer.deserializeLogs(game.value.logs);
 
 	window.setTimeout(() => {
 		logPos.value = 1;
 
 		let i = 1;
-		let previousLog = game.value.logs[0];
+		let previousLog = logs[0];
 		const tick = () => {
-			const log = game.value.logs[i];
-			const time = new Date(log.at).getTime() - new Date(previousLog.at).getTime();
+			const log = logs[i];
+			const time = log.time - previousLog.time;
 			setTimeout(() => {
 				i++;
 				logPos.value++;
 				previousLog = log;
 
-				if (i < game.value.logs.length) {
+				if (i < logs.length) {
 					tick();
 				} else {
 					autoplaying.value = false;
@@ -307,15 +318,15 @@ function autoplay() {
 }
 
 onMounted(() => {
-	props.connection.on('putStone', onPutStone);
-	props.connection.on('rescue', onRescue);
-	props.connection.on('ended', onEnded);
+	props.connection.on('log', onStreamLog);
+	props.connection.on('rescue', onStreamRescue);
+	props.connection.on('ended', onStreamEnded);
 });
 
 onUnmounted(() => {
-	props.connection.off('putStone', onPutStone);
-	props.connection.off('rescue', onRescue);
-	props.connection.off('ended', onEnded);
+	props.connection.off('log', onStreamLog);
+	props.connection.off('rescue', onStreamRescue);
+	props.connection.off('ended', onStreamEnded);
 });
 </script>
 
@@ -389,6 +400,7 @@ $gap: 4px;
 	background: transparent;
 	border-radius: 6px;
 	overflow: clip;
+	aspect-ratio: 1;
 
 	&.boardCell_empty {
 		border: solid 2px var(--divider);
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index e4e7d13668..e1d9d7517c 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -1,6 +1,6 @@
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-19T11:00:07.160Z
+ * generatedAt: 2024-01-20T01:28:01.779Z
  */
 
 import type { SwitchCaseResponseType } from '../api.js';
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 671abd78ce..61fc519c17 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -1,6 +1,6 @@
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-19T11:00:07.158Z
+ * generatedAt: 2024-01-20T01:28:01.777Z
  */
 
 import type {
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index c14876c0e3..79b5fb0ae4 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -1,6 +1,6 @@
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-19T11:00:07.156Z
+ * generatedAt: 2024-01-20T01:28:01.775Z
  */
 
 import { operations } from './types.js';
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index 78f14d2250..fbd32f62d0 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -1,6 +1,6 @@
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-19T11:00:07.155Z
+ * generatedAt: 2024-01-20T01:28:01.774Z
  */
 
 import { components } from './types.js';
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 36facf6e28..d59f510732 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -3,7 +3,7 @@
 
 /*
  * version: 2023.12.2
- * generatedAt: 2024-01-19T11:00:07.077Z
+ * generatedAt: 2024-01-20T01:28:01.695Z
  */
 
 /**
@@ -4517,11 +4517,7 @@ export type components = {
       isLlotheo: boolean;
       canPutEverywhere: boolean;
       loopedBoard: boolean;
-      logs: {
-          at: number;
-          color: boolean;
-          pos: number;
-        }[];
+      logs: unknown[][];
       map: string[];
     };
   };
diff --git a/packages/misskey-reversi/package.json b/packages/misskey-reversi/package.json
index 8d3ca30166..34b29f5b7c 100644
--- a/packages/misskey-reversi/package.json
+++ b/packages/misskey-reversi/package.json
@@ -1,10 +1,22 @@
 {
+	"type": "module",
 	"name": "misskey-reversi",
 	"version": "0.0.1",
-	"main": "./built/index.js",
-	"types": "./built/index.d.ts",
+	"exports": {
+		".": {
+			"import": "./built/esm/index.js",
+			"types": "./built/dts/index.d.ts"
+		},
+		"./*": {
+			"import": "./built/esm/*",
+			"types": "./built/dts/*"
+		}
+	},
 	"scripts": {
-		"build": "tsc",
+		"build": "npm run ts",
+		"ts": "npm run ts-esm && npm run ts-dts",
+		"ts-esm": "tsc --outDir built/esm",
+		"ts-dts": "tsc --outDir built/dts --declaration true --emitDeclarationOnly true --declarationMap true",
 		"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build\"",
 		"eslint": "eslint . --ext .js,.jsx,.ts,.tsx",
 		"typecheck": "tsc --noEmit",
@@ -16,6 +28,7 @@
 		"@typescript-eslint/eslint-plugin": "6.19.0",
 		"@typescript-eslint/parser": "6.19.0",
 		"eslint": "8.56.0",
+		"nodemon": "3.0.2",
 		"typescript": "5.3.3"
 	},
 	"files": [
diff --git a/packages/misskey-reversi/src/game.ts b/packages/misskey-reversi/src/game.ts
index 72aace7c0b..f29b001447 100644
--- a/packages/misskey-reversi/src/game.ts
+++ b/packages/misskey-reversi/src/game.ts
@@ -46,7 +46,7 @@ export class Game {
 
 	constructor(map: string[], opts: Options) {
 		//#region binds
-		this.put = this.put.bind(this);
+		this.putStone = this.putStone.bind(this);
 		//#endregion
 
 		//#region Options
@@ -88,7 +88,10 @@ export class Game {
 		return x + (y * this.mapWidth);
 	}
 
-	public put(color: Color, pos: number) {
+	public putStone(pos: number) {
+		const color = this.turn;
+		if (color == null) return;
+
 		this.prevPos = pos;
 		this.prevColor = color;
 
diff --git a/packages/misskey-reversi/src/index.ts b/packages/misskey-reversi/src/index.ts
index 28964413b7..883b16e3d7 100644
--- a/packages/misskey-reversi/src/index.ts
+++ b/packages/misskey-reversi/src/index.ts
@@ -3,10 +3,6 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { Game } from './game.js';
-
-export {
-	Game,
-};
-
+export { Game } from './game.js';
+export * as Serializer from './serializer.js';
 export * as maps from './maps.js';
diff --git a/packages/misskey-reversi/src/serializer.ts b/packages/misskey-reversi/src/serializer.ts
new file mode 100644
index 0000000000..2e6e0475d6
--- /dev/null
+++ b/packages/misskey-reversi/src/serializer.ts
@@ -0,0 +1,98 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Game } from './game.js';
+
+export type Log = {
+	time: number;
+	player: boolean;
+	operation: 'put';
+	pos: number;
+};
+
+export type SerializedLog = number[];
+
+export function serializeLogs(logs: Log[]) {
+	const _logs: number[][] = [];
+
+	for (let i = 0; i < logs.length; i++) {
+		const log = logs[i];
+		const timeDelta = i === 0 ? log.time : log.time - logs[i - 1].time;
+
+		switch (log.operation) {
+			case 'put':
+				_logs.push([timeDelta, log.player ? 1 : 0, 0, log.pos]);
+				break;
+			//case 'surrender':
+			//	_logs.push([timeDelta, log.player, 1]);
+			//	break;
+		}
+	}
+
+	return _logs;
+}
+
+export function deserializeLogs(logs: SerializedLog[]) {
+	const _logs: Log[] = [];
+
+	let time = 0;
+
+	for (const log of logs) {
+		const timeDelta = log[0];
+		time += timeDelta;
+
+		const player = log[1];
+		const operation = log[2];
+
+		switch (operation) {
+			case 0:
+				_logs.push({
+					time,
+					player: player === 1,
+					operation: 'put',
+					pos: log[3],
+				});
+				break;
+			//case 1:
+			//	_logs.push({
+			//		time,
+			//		player: player === 1,
+			//		operation: 'surrender',
+			//	});
+			//	break;
+		}
+	}
+
+	return _logs;
+}
+
+export function restoreGame(env: {
+	map: string[];
+	isLlotheo: boolean;
+	canPutEverywhere: boolean;
+	loopedBoard: boolean;
+	logs: SerializedLog[];
+}) {
+	const logs = deserializeLogs(env.logs);
+
+	const game = new Game(env.map, {
+		isLlotheo: env.isLlotheo,
+		canPutEverywhere: env.canPutEverywhere,
+		loopedBoard: env.loopedBoard,
+	});
+
+	for (const log of logs) {
+		switch (log.operation) {
+			case 'put':
+				game.putStone(log.pos);
+				break;
+			//case 'surrender':
+			//	game.surrender(log.player);
+			//	break;
+		}
+	}
+
+	return game;
+}
-- 
GitLab