summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2024-01-19 20:51:49 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2024-01-19 20:51:49 +0900
commita637b4e28259e89285fc1c67589c731a053f5562 (patch)
treebda9783430b2e75e14484d0bd0ef0541c160c09e /packages/backend/src/core
parentEnhance(frontend): MFMの属性にオートコンプリートが利用でき... (diff)
downloadsharkey-a637b4e28259e89285fc1c67589c731a053f5562.tar.gz
sharkey-a637b4e28259e89285fc1c67589c731a053f5562.tar.bz2
sharkey-a637b4e28259e89285fc1c67589c731a053f5562.zip
feat: reversi
Resolve #12962
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/CoreModule.ts27
-rw-r--r--packages/backend/src/core/GlobalEventService.ts57
-rw-r--r--packages/backend/src/core/ReversiService.ts411
-rw-r--r--packages/backend/src/core/entities/ReversiGameEntityService.ts115
4 files changed, 609 insertions, 1 deletions
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index bc6d24b951..c9e285346e 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -66,6 +66,8 @@ import { FeaturedService } from './FeaturedService.js';
import { FanoutTimelineService } from './FanoutTimelineService.js';
import { ChannelFollowingService } from './ChannelFollowingService.js';
import { RegistryApiService } from './RegistryApiService.js';
+import { ReversiService } from './ReversiService.js';
+
import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js';
import NotesChart from './chart/charts/notes.js';
@@ -80,6 +82,7 @@ import PerUserFollowingChart from './chart/charts/per-user-following.js';
import PerUserDriveChart from './chart/charts/per-user-drive.js';
import ApRequestChart from './chart/charts/ap-request.js';
import { ChartManagementService } from './chart/ChartManagementService.js';
+
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
import { AntennaEntityService } from './entities/AntennaEntityService.js';
import { AppEntityService } from './entities/AppEntityService.js';
@@ -112,6 +115,8 @@ import { UserListEntityService } from './entities/UserListEntityService.js';
import { FlashEntityService } from './entities/FlashEntityService.js';
import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
import { RoleEntityService } from './entities/RoleEntityService.js';
+import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
+
import { ApAudienceService } from './activitypub/ApAudienceService.js';
import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
@@ -199,6 +204,7 @@ const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', use
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
+const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@@ -247,6 +253,7 @@ const $UserListEntityService: Provider = { provide: 'UserListEntityService', use
const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService };
const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService };
+const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
@@ -336,6 +343,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineEndpointService,
ChannelFollowingService,
RegistryApiService,
+ ReversiService,
+
ChartLoggerService,
FederationChart,
NotesChart,
@@ -350,6 +359,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
PerUserDriveChart,
ApRequestChart,
ChartManagementService,
+
AbuseUserReportEntityService,
AntennaEntityService,
AppEntityService,
@@ -382,6 +392,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FlashEntityService,
FlashLikeEntityService,
RoleEntityService,
+ ReversiGameEntityService,
+
ApAudienceService,
ApDbResolverService,
ApDeliverManagerService,
@@ -466,6 +478,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineEndpointService,
$ChannelFollowingService,
$RegistryApiService,
+ $ReversiService,
+
$ChartLoggerService,
$FederationChart,
$NotesChart,
@@ -480,6 +494,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$PerUserDriveChart,
$ApRequestChart,
$ChartManagementService,
+
$AbuseUserReportEntityService,
$AntennaEntityService,
$AppEntityService,
@@ -512,6 +527,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FlashEntityService,
$FlashLikeEntityService,
$RoleEntityService,
+ $ReversiGameEntityService,
+
$ApAudienceService,
$ApDbResolverService,
$ApDeliverManagerService,
@@ -597,6 +614,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineEndpointService,
ChannelFollowingService,
RegistryApiService,
+ ReversiService,
+
FederationChart,
NotesChart,
UsersChart,
@@ -610,6 +629,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
PerUserDriveChart,
ApRequestChart,
ChartManagementService,
+
AbuseUserReportEntityService,
AntennaEntityService,
AppEntityService,
@@ -642,6 +662,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FlashEntityService,
FlashLikeEntityService,
RoleEntityService,
+ ReversiGameEntityService,
+
ApAudienceService,
ApDbResolverService,
ApDeliverManagerService,
@@ -726,6 +748,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineEndpointService,
$ChannelFollowingService,
$RegistryApiService,
+ $ReversiService,
+
$FederationChart,
$NotesChart,
$UsersChart,
@@ -739,6 +763,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$PerUserDriveChart,
$ApRequestChart,
$ChartManagementService,
+
$AbuseUserReportEntityService,
$AntennaEntityService,
$AppEntityService,
@@ -771,6 +796,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FlashEntityService,
$FlashLikeEntityService,
$RoleEntityService,
+ $ReversiGameEntityService,
+
$ApAudienceService,
$ApDbResolverService,
$ApDeliverManagerService,
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index d175f21f2f..11a8935be2 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -18,7 +18,7 @@ import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiMeta } from '@/models/Meta.js';
-import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js';
+import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -159,6 +159,43 @@ export interface AdminEventTypes {
comment: string;
};
}
+
+export interface ReversiEventTypes {
+ matched: {
+ game: Packed<'ReversiGameDetailed'>;
+ };
+ invited: {
+ user: Packed<'User'>;
+ };
+}
+
+export interface ReversiGameEventTypes {
+ changeReadyStates: {
+ user1: boolean;
+ user2: boolean;
+ };
+ updateSettings: {
+ userId: MiUser['id'];
+ key: string;
+ value: any;
+ };
+ putStone: {
+ at: number;
+ color: boolean;
+ pos: number;
+ next: boolean;
+ };
+ syncState: {
+ crc32: string;
+ };
+ started: {
+ game: Packed<'ReversiGameDetailed'>;
+ };
+ ended: {
+ winnerId: MiUser['id'] | null;
+ game: Packed<'ReversiGameDetailed'>;
+ };
+}
//#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義
@@ -249,6 +286,14 @@ export type GlobalEvents = {
name: 'notesStream';
payload: Serialized<Packed<'Note'>>;
};
+ reversi: {
+ name: `reversiStream:${MiUser['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<ReversiEventTypes>>;
+ };
+ reversiGame: {
+ name: `reversiGameStream:${MiReversiGame['id']}`;
+ payload: EventUnionFromDictionary<SerializedAll<ReversiGameEventTypes>>;
+ };
};
// API event definitions
@@ -338,4 +383,14 @@ export class GlobalEventService {
public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void {
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
+
+ @bindThis
+ public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void {
+ this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ @bindThis
+ public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
+ this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
+ }
}
diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts
new file mode 100644
index 0000000000..cd990ba775
--- /dev/null
+++ b/packages/backend/src/core/ReversiService.ts
@@ -0,0 +1,411 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import CRC32 from 'crc-32';
+import { ModuleRef } from '@nestjs/core';
+import * as Reversi from 'misskey-reversi';
+import { IsNull } from 'typeorm';
+import type {
+ MiReversiGame,
+ ReversiGamesRepository,
+ UsersRepository,
+} from '@/models/_.js';
+import type { MiUser } from '@/models/User.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import { MetaService } from '@/core/MetaService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { IdService } from '@/core/IdService.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { NotificationService } from '@/core/NotificationService.js';
+import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
+import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
+
+const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec
+
+@Injectable()
+export class ReversiService implements OnApplicationShutdown, OnModuleInit {
+ private notificationService: NotificationService;
+
+ constructor(
+ private moduleRef: ModuleRef,
+
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
+ @Inject(DI.reversiGamesRepository)
+ private reversiGamesRepository: ReversiGamesRepository,
+
+ private cacheService: CacheService,
+ private userEntityService: UserEntityService,
+ private globalEventService: GlobalEventService,
+ private reversiGameEntityService: ReversiGameEntityService,
+ private idService: IdService,
+ ) {
+ }
+
+ async onModuleInit() {
+ this.notificationService = this.moduleRef.get(NotificationService.name);
+ }
+
+ @bindThis
+ public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> {
+ if (targetUser.id === me.id) {
+ throw new Error('You cannot match yourself.');
+ }
+
+ const invitations = await this.redisClient.zrange(
+ `reversi:matchSpecific:${me.id}`,
+ Date.now() - MATCHING_TIMEOUT_MS,
+ '+inf',
+ 'BYSCORE');
+
+ if (invitations.includes(targetUser.id)) {
+ await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
+
+ const game = await this.reversiGamesRepository.insert({
+ id: this.idService.gen(),
+ user1Id: targetUser.id,
+ user2Id: me.id,
+ user1Ready: false,
+ user2Ready: false,
+ isStarted: false,
+ isEnded: false,
+ logs: [],
+ map: Reversi.maps.eighteight.data,
+ bw: 'random',
+ isLlotheo: false,
+ }).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
+
+ const packed = await this.reversiGameEntityService.packDetail(game, { id: targetUser.id });
+ this.globalEventService.publishReversiStream(targetUser.id, 'matched', { game: packed });
+
+ return game;
+ } else {
+ this.redisClient.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
+
+ this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
+ user: await this.userEntityService.pack(me, targetUser),
+ });
+
+ return null;
+ }
+ }
+
+ @bindThis
+ public async matchAnyUser(me: MiUser): Promise<MiReversiGame | null> {
+ //#region まず自分宛ての招待を探す
+ const invitations = await this.redisClient.zrange(
+ `reversi:matchSpecific:${me.id}`,
+ Date.now() - MATCHING_TIMEOUT_MS,
+ '+inf',
+ 'BYSCORE');
+
+ if (invitations.length > 0) {
+ const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
+ await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
+
+ const game = await this.reversiGamesRepository.insert({
+ id: this.idService.gen(),
+ user1Id: invitorId,
+ user2Id: me.id,
+ user1Ready: false,
+ user2Ready: false,
+ isStarted: false,
+ isEnded: false,
+ logs: [],
+ map: Reversi.maps.eighteight.data,
+ bw: 'random',
+ isLlotheo: false,
+ }).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
+
+ const packed = await this.reversiGameEntityService.packDetail(game, { id: invitorId });
+ this.globalEventService.publishReversiStream(invitorId, 'matched', { game: packed });
+
+ return game;
+ }
+ //#endregion
+
+ const matchings = await this.redisClient.zrange(
+ 'reversi:matchAny',
+ Date.now() - MATCHING_TIMEOUT_MS,
+ '+inf',
+ 'BYSCORE');
+
+ const userIds = matchings.filter(id => id !== me.id);
+
+ if (userIds.length > 0) {
+ // pick random
+ const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)];
+
+ await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId);
+
+ const game = await this.reversiGamesRepository.insert({
+ id: this.idService.gen(),
+ user1Id: matchedUserId,
+ user2Id: me.id,
+ user1Ready: false,
+ user2Ready: false,
+ isStarted: false,
+ isEnded: false,
+ logs: [],
+ map: Reversi.maps.eighteight.data,
+ bw: 'random',
+ isLlotheo: false,
+ }).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
+
+ const packed = await this.reversiGameEntityService.packDetail(game, { id: matchedUserId });
+ this.globalEventService.publishReversiStream(matchedUserId, 'matched', { game: packed });
+
+ return game;
+ } else {
+ await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id);
+ return null;
+ }
+ }
+
+ @bindThis
+ public async matchSpecificUserCancel(user: MiUser, targetUserId: MiUser['id']) {
+ await this.redisClient.zrem(`reversi:matchSpecific:${targetUserId}`, user.id);
+ }
+
+ @bindThis
+ public async matchAnyUserCancel(user: MiUser) {
+ await this.redisClient.zrem('reversi:matchAny', user.id);
+ }
+
+ @bindThis
+ public async gameReady(game: MiReversiGame, user: MiUser, ready: boolean) {
+ if (game.isStarted) return;
+
+ let isBothReady = false;
+
+ if (game.user1Id === user.id) {
+ await this.reversiGamesRepository.update(game.id, {
+ user1Ready: ready,
+ });
+
+ this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
+ user1: ready,
+ user2: game.user2Ready,
+ });
+
+ if (ready && game.user2Ready) isBothReady = true;
+ } else if (game.user2Id === user.id) {
+ await this.reversiGamesRepository.update(game.id, {
+ user2Ready: ready,
+ });
+
+ this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
+ user1: game.user1Ready,
+ user2: ready,
+ });
+
+ if (ready && game.user1Ready) isBothReady = true;
+ } else {
+ return;
+ }
+
+ if (isBothReady) {
+ // 3秒後、両者readyならゲーム開始
+ setTimeout(async () => {
+ const freshGame = await this.reversiGamesRepository.findOneBy({ id: 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();
+
+ await this.reversiGamesRepository.update(game.id, {
+ startedAt: new Date(),
+ isStarted: true,
+ black: bw,
+ map: map,
+ });
+
+ //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
+ const o = new Reversi.Game(map, {
+ isLlotheo: freshGame.isLlotheo,
+ canPutEverywhere: freshGame.canPutEverywhere,
+ loopedBoard: freshGame.loopedBoard,
+ });
+
+ if (o.isEnded) {
+ let winner;
+ if (o.winner === true) {
+ winner = freshGame.black === 1 ? freshGame.user1Id : freshGame.user2Id;
+ } else if (o.winner === false) {
+ winner = freshGame.black === 1 ? freshGame.user2Id : freshGame.user1Id;
+ } else {
+ winner = null;
+ }
+
+ await this.reversiGamesRepository.update(game.id, {
+ isEnded: true,
+ winnerId: winner,
+ });
+
+ this.globalEventService.publishReversiGameStream(game.id, 'ended', {
+ winnerId: winner,
+ game: await this.reversiGameEntityService.packDetail(game.id, user),
+ });
+ }
+ //#endregion
+
+ this.globalEventService.publishReversiGameStream(game.id, 'started', {
+ game: await this.reversiGameEntityService.packDetail(game.id, user),
+ });
+ }, 3000);
+ }
+ }
+
+ @bindThis
+ public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
+ const invitations = await this.redisClient.zrange(
+ `reversi:matchSpecific:${user.id}`,
+ Date.now() - MATCHING_TIMEOUT_MS,
+ '+inf',
+ 'BYSCORE');
+ return invitations;
+ }
+
+ @bindThis
+ public async updateSettings(game: MiReversiGame, user: MiUser, key: string, value: any) {
+ if (game.isStarted) return;
+ if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
+ if ((game.user1Id === user.id) && game.user1Ready) return;
+ if ((game.user2Id === user.id) && game.user2Ready) return;
+
+ if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard'].includes(key)) return;
+
+ await this.reversiGamesRepository.update(game.id, {
+ [key]: value,
+ });
+
+ this.globalEventService.publishReversiGameStream(game.id, 'updateSettings', {
+ userId: user.id,
+ key: key,
+ value: value,
+ });
+ }
+
+ @bindThis
+ public async putStoneToGame(game: MiReversiGame, user: MiUser, pos: number) {
+ if (!game.isStarted) return;
+ if (game.isEnded) return;
+ if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
+
+ const myColor =
+ ((game.user1Id === user.id) && game.black === 1) || ((game.user2Id === user.id) && game.black === 2)
+ ? true
+ : false;
+
+ const o = new Reversi.Game(game.map, {
+ isLlotheo: game.isLlotheo,
+ canPutEverywhere: game.canPutEverywhere,
+ loopedBoard: game.loopedBoard,
+ });
+
+ // 盤面の状態を再生
+ for (const log of game.logs) {
+ o.put(log.color, log.pos);
+ }
+
+ if (o.turn !== myColor) return;
+
+ if (!o.canPut(myColor, pos)) return;
+ o.put(myColor, pos);
+
+ let winner;
+ if (o.isEnded) {
+ if (o.winner === true) {
+ winner = game.black === 1 ? game.user1Id : game.user2Id;
+ } else if (o.winner === false) {
+ winner = game.black === 1 ? game.user2Id : game.user1Id;
+ } else {
+ winner = null;
+ }
+ }
+
+ const log = {
+ at: Date.now(),
+ color: myColor,
+ pos,
+ };
+
+ const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()).toString();
+
+ game.logs.push(log);
+
+ await this.reversiGamesRepository.update(game.id, {
+ crc32,
+ isEnded: o.isEnded,
+ winnerId: winner,
+ logs: game.logs,
+ });
+
+ this.globalEventService.publishReversiGameStream(game.id, 'putStone', {
+ ...log,
+ next: o.turn,
+ });
+
+ if (o.isEnded) {
+ this.globalEventService.publishReversiGameStream(game.id, 'ended', {
+ winnerId: winner,
+ game: await this.reversiGameEntityService.packDetail(game.id, user),
+ });
+ }
+ }
+
+ @bindThis
+ public async surrender(game: MiReversiGame, user: MiUser) {
+ if (game.isEnded) return;
+ if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
+
+ const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id;
+
+ await this.reversiGamesRepository.update(game.id, {
+ surrendered: user.id,
+ isEnded: true,
+ winnerId: winnerId,
+ });
+
+ this.globalEventService.publishReversiGameStream(game.id, 'ended', {
+ winnerId: winnerId,
+ game: await this.reversiGameEntityService.packDetail(game.id, user),
+ });
+ }
+
+ @bindThis
+ public async get(id: MiReversiGame['id']) {
+ return this.reversiGamesRepository.findOneBy({ id });
+ }
+
+ @bindThis
+ public dispose(): void {
+ }
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
+}
diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts
new file mode 100644
index 0000000000..8d95204928
--- /dev/null
+++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts
@@ -0,0 +1,115 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { ReversiGamesRepository } from '@/models/_.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/json-schema.js';
+import type { } from '@/models/Blocking.js';
+import type { MiUser } from '@/models/User.js';
+import type { MiReversiGame } from '@/models/ReversiGame.js';
+import { bindThis } from '@/decorators.js';
+import { IdService } from '@/core/IdService.js';
+import { UserEntityService } from './UserEntityService.js';
+
+@Injectable()
+export class ReversiGameEntityService {
+ constructor(
+ @Inject(DI.reversiGamesRepository)
+ private reversiGamesRepository: ReversiGamesRepository,
+
+ private userEntityService: UserEntityService,
+ private idService: IdService,
+ ) {
+ }
+
+ @bindThis
+ public async packDetail(
+ src: MiReversiGame['id'] | MiReversiGame,
+ me?: { id: MiUser['id'] } | null | undefined,
+ ): Promise<Packed<'ReversiGameDetailed'>> {
+ const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
+
+ return await awaitAll({
+ id: game.id,
+ createdAt: this.idService.parse(game.id).date.toISOString(),
+ startedAt: game.startedAt && game.startedAt.toISOString(),
+ isStarted: game.isStarted,
+ isEnded: game.isEnded,
+ form1: game.form1,
+ form2: game.form2,
+ user1Ready: game.user1Ready,
+ user2Ready: game.user2Ready,
+ user1Id: game.user1Id,
+ user2Id: game.user2Id,
+ user1: this.userEntityService.pack(game.user1Id, me),
+ user2: this.userEntityService.pack(game.user2Id, me),
+ winnerId: game.winnerId,
+ winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
+ surrendered: game.surrendered,
+ black: game.black,
+ bw: game.bw,
+ isLlotheo: game.isLlotheo,
+ canPutEverywhere: game.canPutEverywhere,
+ loopedBoard: game.loopedBoard,
+ logs: game.logs.map(log => ({
+ at: log.at,
+ color: log.color,
+ pos: log.pos,
+ })),
+ map: game.map,
+ });
+ }
+
+ @bindThis
+ public packDetailMany(
+ xs: MiReversiGame[],
+ me?: { id: MiUser['id'] } | null | undefined,
+ ) {
+ return Promise.all(xs.map(x => this.packDetail(x, me)));
+ }
+
+ @bindThis
+ public async packLite(
+ src: MiReversiGame['id'] | MiReversiGame,
+ me?: { id: MiUser['id'] } | null | undefined,
+ ): Promise<Packed<'ReversiGameLite'>> {
+ const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
+
+ return await awaitAll({
+ id: game.id,
+ createdAt: this.idService.parse(game.id).date.toISOString(),
+ startedAt: game.startedAt && game.startedAt.toISOString(),
+ isStarted: game.isStarted,
+ isEnded: game.isEnded,
+ form1: game.form1,
+ form2: game.form2,
+ user1Ready: game.user1Ready,
+ user2Ready: game.user2Ready,
+ user1Id: game.user1Id,
+ user2Id: game.user2Id,
+ user1: this.userEntityService.pack(game.user1Id, me),
+ user2: this.userEntityService.pack(game.user2Id, me),
+ winnerId: game.winnerId,
+ winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
+ surrendered: game.surrendered,
+ black: game.black,
+ bw: game.bw,
+ isLlotheo: game.isLlotheo,
+ canPutEverywhere: game.canPutEverywhere,
+ loopedBoard: game.loopedBoard,
+ });
+ }
+
+ @bindThis
+ public packLiteMany(
+ xs: MiReversiGame[],
+ me?: { id: MiUser['id'] } | null | undefined,
+ ) {
+ return Promise.all(xs.map(x => this.packLite(x, me)));
+ }
+}
+