From a637b4e28259e89285fc1c67589c731a053f5562 Mon Sep 17 00:00:00 2001 From: syuilo Date: Fri, 19 Jan 2024 20:51:49 +0900 Subject: feat: reversi Resolve #12962 --- .../src/server/api/stream/ChannelsService.ts | 6 + .../src/server/api/stream/channels/reversi-game.ts | 130 +++++++++++++++++++++ .../src/server/api/stream/channels/reversi.ts | 52 +++++++++ 3 files changed, 188 insertions(+) create mode 100644 packages/backend/src/server/api/stream/channels/reversi-game.ts create mode 100644 packages/backend/src/server/api/stream/channels/reversi.ts (limited to 'packages/backend/src/server/api/stream') diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 3bc5380132..998429dd0a 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -19,6 +19,8 @@ import { AntennaChannelService } from './channels/antenna.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; import { RoleTimelineChannelService } from './channels/role-timeline.js'; +import { ReversiChannelService } from './channels/reversi.js'; +import { ReversiGameChannelService } from './channels/reversi-game.js'; import { type MiChannelService } from './channel.js'; @Injectable() @@ -38,6 +40,8 @@ export class ChannelsService { private serverStatsChannelService: ServerStatsChannelService, private queueStatsChannelService: QueueStatsChannelService, private adminChannelService: AdminChannelService, + private reversiChannelService: ReversiChannelService, + private reversiGameChannelService: ReversiGameChannelService, ) { } @@ -58,6 +62,8 @@ export class ChannelsService { case 'serverStats': return this.serverStatsChannelService; case 'queueStats': return this.queueStatsChannelService; case 'admin': return this.adminChannelService; + case 'reversi': return this.reversiChannelService; + case 'reversiGame': return this.reversiGameChannelService; default: throw new Error(`no such channel: ${name}`); diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts new file mode 100644 index 0000000000..c67c05fb09 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { MiReversiGame, ReversiGamesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { ReversiService } from '@/core/ReversiService.js'; +import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class ReversiGameChannel extends Channel { + public readonly chName = 'reversiGame'; + public static shouldShare = false; + public static requireCredential = false as const; + private gameId: MiReversiGame['id'] | null = null; + + constructor( + private reversiService: ReversiService, + private reversiGamesRepository: ReversiGamesRepository, + private reversiGameEntityService: ReversiGameEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: any) { + this.gameId = params.gameId as string; + + const game = await this.reversiGamesRepository.findOneBy({ + id: this.gameId, + }); + if (game == null) return; + + this.subscriber.on(`reversiGameStream:${this.gameId}`, this.send); + } + + @bindThis + public onMessage(type: string, body: any) { + 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 'syncState': this.syncState(body.crc32); break; + } + } + + @bindThis + private async updateSettings(key: string, value: any) { + 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.updateSettings(game, this.user, key, value); + } + + @bindThis + private async ready(ready: boolean) { + if (this.user == null) return; + + const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! }); + if (game == null) throw new Error('game not found'); + + this.reversiService.gameReady(game, this.user, ready); + } + + @bindThis + private async putStone(pos: number) { + 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); + } + + @bindThis + private async syncState(crc32: string | number) { + // TODO: キャッシュしたい + const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! }); + if (game == null) throw new Error('game not found'); + + if (!game.isStarted) return; + + if (crc32.toString() !== game.crc32) { + this.send('rescue', await this.reversiGameEntityService.packDetail(game, this.user)); + } + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off(`reversiGameStream:${this.gameId}`, this.send); + } +} + +@Injectable() +export class ReversiGameChannelService implements MiChannelService { + public readonly shouldShare = ReversiGameChannel.shouldShare; + public readonly requireCredential = ReversiGameChannel.requireCredential; + public readonly kind = ReversiGameChannel.kind; + + constructor( + @Inject(DI.reversiGamesRepository) + private reversiGamesRepository: ReversiGamesRepository, + + private reversiService: ReversiService, + private reversiGameEntityService: ReversiGameEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): ReversiGameChannel { + return new ReversiGameChannel( + this.reversiService, + this.reversiGamesRepository, + this.reversiGameEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/reversi.ts b/packages/backend/src/server/api/stream/channels/reversi.ts new file mode 100644 index 0000000000..cb4b1b8d5a --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/reversi.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class ReversiChannel extends Channel { + public readonly chName = 'reversi'; + public static shouldShare = true; + public static requireCredential = true as const; + public static kind = 'read:account'; + + constructor( + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: any) { + this.subscriber.on(`reversiStream:${this.user!.id}`, this.send); + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off(`reversiStream:${this.user!.id}`, this.send); + } +} + +@Injectable() +export class ReversiChannelService implements MiChannelService { + public readonly shouldShare = ReversiChannel.shouldShare; + public readonly requireCredential = ReversiChannel.requireCredential; + public readonly kind = ReversiChannel.kind; + + constructor( + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): ReversiChannel { + return new ReversiChannel( + id, + connection, + ); + } +} -- cgit v1.2.3-freya From b9a81edae5bfbd2a5b0d03e9b523a04ea5cf4bc5 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sat, 20 Jan 2024 13:14:46 +0900 Subject: enhance(reversi): tweak reversi --- packages/backend/src/core/GlobalEventService.ts | 8 +- packages/backend/src/core/ReversiService.ts | 51 +++++----- .../src/core/entities/ReversiGameEntityService.ts | 6 +- packages/backend/src/models/ReversiGame.ts | 9 +- .../backend/src/models/json-schema/reversi-game.ts | 16 +-- .../src/server/api/stream/channels/reversi-game.ts | 6 +- packages/frontend/src/pages/reversi/game.board.vue | 110 ++++++++++++--------- packages/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 (limited to 'packages/backend/src/server/api/stream') 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 -
+
{{ i18n.ts._reversi.opponentTurn }}
{{ i18n.ts._reversi.myTurn }}
-
+