diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 11a8935be..896149f23 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 6e8026133..9fe7255e4 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 (engine.turn !== myColor) return;
+ if (!engine.canPut(myColor, pos)) return;
- if (o.turn !== myColor) 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;
- const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()).toString();
+ logs.push(log);
- game.logs.push(log);
+ const serializeLogs = Reversi.Serializer.serializeLogs(logs);
+
+ 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 8d9520492..a7adc681f 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 d297d1f01..dcaa5c9fa 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 0d23b9dc7..b94046438 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 c67c05fb0..2d8c396db 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 582967ad2..bf45fc411 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
-
+
{{ i18n.ts._reversi.opponentTurn }}
{{ i18n.ts._reversi.myTurn }}
-
+
({{ i18n.ts._reversi.surrendered }})
@@ -69,12 +69,12 @@ SPDX-License-Identifier: AGPL-3.0-only
-
{{ logPos }} / {{ logs.length }}
+
{{ logPos }} / {{ game.logs.length }}
-
-
+
+
@@ -115,18 +115,15 @@ const props = defineProps<{
const showBoardLabels = true;
const autoplaying = ref
(false);
const game = ref(deepClone(props.game));
-const logs = ref(game.value.logs);
-const logPos = ref(logs.value.length);
-const engine = shallowRef(new Reversi.Game(game.value.map, {
+const logPos = ref(game.value.logs.length);
+const engine = shallowRef(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);
- logPos.value++;
- engine.value.put(x.color, x.pos);
- triggerRef(engine);
- checkEnd();
+function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
+ game.value.logs = Reversi.Serializer.serializeLogs([
+ ...Reversi.Serializer.deserializeLogs(game.value.logs),
+ log,
+ ]);
- // サウンドを再生する
- if (x.color !== myColor.value) {
- //sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
+ logPos.value++;
+
+ 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);
});
@@ -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 e4e7d1366..e1d9d7517 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 671abd78c..61fc519c1 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 c14876c0e..79b5fb0ae 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 78f14d225..fbd32f62d 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 36facf6e2..d59f51073 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 8d3ca3016..34b29f5b7 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 72aace7c0..f29b00144 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 28964413b..883b16e3d 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 000000000..2e6e0475d
--- /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;
+}