summaryrefslogtreecommitdiff
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
parentEnhance(frontend): MFMの属性にオートコンプリートが利用でき... (diff)
downloadmisskey-a637b4e28259e89285fc1c67589c731a053f5562.tar.gz
misskey-a637b4e28259e89285fc1c67589c731a053f5562.tar.bz2
misskey-a637b4e28259e89285fc1c67589c731a053f5562.zip
feat: reversi
Resolve #12962
-rw-r--r--locales/index.d.ts35
-rw-r--r--locales/ja-JP.yml35
-rw-r--r--packages/backend/migration/1705475608437-reversi.js22
-rw-r--r--packages/backend/migration/1705654039457-reversi-2.js18
-rw-r--r--packages/backend/package.json2
-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
-rw-r--r--packages/backend/src/di-symbols.ts1
-rw-r--r--packages/backend/src/misc/json-schema.ts3
-rw-r--r--packages/backend/src/models/RepositoryModule.ts12
-rw-r--r--packages/backend/src/models/ReversiGame.ts127
-rw-r--r--packages/backend/src/models/_.ts4
-rw-r--r--packages/backend/src/models/json-schema/reversi-game.ts234
-rw-r--r--packages/backend/src/postgres.ts2
-rw-r--r--packages/backend/src/server/ServerModule.ts11
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts24
-rw-r--r--packages/backend/src/server/api/endpoints.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/renote-mute/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/reversi/cancel-match.ts44
-rw-r--r--packages/backend/src/server/api/endpoints/reversi/games.ts61
-rw-r--r--packages/backend/src/server/api/endpoints/reversi/invitations.ts39
-rw-r--r--packages/backend/src/server/api/endpoints/reversi/match.ts66
-rw-r--r--packages/backend/src/server/api/endpoints/reversi/show-game.ts54
-rw-r--r--packages/backend/src/server/api/endpoints/reversi/surrender.ts68
-rw-r--r--packages/backend/src/server/api/stream/ChannelsService.ts6
-rw-r--r--packages/backend/src/server/api/stream/channels/reversi-game.ts130
-rw-r--r--packages/backend/src/server/api/stream/channels/reversi.ts52
-rw-r--r--packages/frontend/assets/reversi/logo.pngbin0 -> 96293 bytes
-rw-r--r--packages/frontend/package.json2
-rw-r--r--packages/frontend/src/components/MkRadios.vue3
-rw-r--r--packages/frontend/src/components/MkSelect.vue4
-rw-r--r--packages/frontend/src/components/MkUserSelectDialog.vue12
-rw-r--r--packages/frontend/src/global/router/definition.ts17
-rw-r--r--packages/frontend/src/os.ts2
-rw-r--r--packages/frontend/src/pages/drop-and-fusion.vue2
-rw-r--r--packages/frontend/src/pages/games.vue15
-rw-r--r--packages/frontend/src/pages/reversi/game.board.vue428
-rw-r--r--packages/frontend/src/pages/reversi/game.setting.vue236
-rw-r--r--packages/frontend/src/pages/reversi/game.vue68
-rw-r--r--packages/frontend/src/pages/reversi/index.vue271
-rw-r--r--packages/frontend/vite.config.ts4
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md50
-rw-r--r--packages/misskey-js/src/autogen/apiClientJSDoc.ts68
-rw-r--r--packages/misskey-js/src/autogen/endpoint.ts18
-rw-r--r--packages/misskey-js/src/autogen/entities.ts12
-rw-r--r--packages/misskey-js/src/autogen/models.ts4
-rw-r--r--packages/misskey-js/src/autogen/types.ts442
-rw-r--r--packages/misskey-reversi/package.json26
-rw-r--r--packages/misskey-reversi/src/game.ts216
-rw-r--r--packages/misskey-reversi/src/index.ts7
-rw-r--r--packages/misskey-reversi/src/maps.ts715
-rw-r--r--packages/misskey-reversi/tsconfig.json33
-rw-r--r--pnpm-lock.yaml479
-rw-r--r--pnpm-workspace.yaml1
56 files changed, 4701 insertions, 108 deletions
diff --git a/locales/index.d.ts b/locales/index.d.ts
index a22cb63507..85e0c6b244 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -2633,6 +2633,41 @@ export interface Locale extends ILocale {
"description": string;
};
};
+ "_reversi": {
+ "reversi": string;
+ "gameSettings": string;
+ "chooseBoard": string;
+ "blackOrWhite": string;
+ "blackIs": ParameterizedString<"name">;
+ "rules": string;
+ "thisGameIsStartedSoon": string;
+ "waitingForOther": string;
+ "waitingForMe": string;
+ "waitingBoth": string;
+ "ready": string;
+ "cancelReady": string;
+ "opponentTurn": string;
+ "myTurn": string;
+ "turnOf": ParameterizedString<"name">;
+ "pastTurnOf": ParameterizedString<"name">;
+ "surrender": string;
+ "surrendered": string;
+ "drawn": string;
+ "won": ParameterizedString<"name">;
+ "black": string;
+ "white": string;
+ "total": string;
+ "turnCount": ParameterizedString<"count">;
+ "myGames": string;
+ "allGames": string;
+ "ended": string;
+ "playing": string;
+ "isLlotheo": string;
+ "loopedMap": string;
+ "canPutEverywhere": string;
+ "freeMatch": string;
+ "lookingForPlayer": string;
+ };
}
declare const locales: {
[lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 8749a5f49f..6c8a453023 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2506,3 +2506,38 @@ _dataSaver:
_code:
title: "コードハイライト"
description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。"
+
+_reversi:
+ reversi: "リバーシ"
+ gameSettings: "対局の設定"
+ chooseBoard: "ボードを選択"
+ blackOrWhite: "先行/後攻"
+ blackIs: "{name}が黒(先行)"
+ rules: "ルール"
+ thisGameIsStartedSoon: "対局はまもなく開始されます"
+ waitingForOther: "相手の準備が完了するのを待っています"
+ waitingForMe: "あなたの準備が完了するのを待っています"
+ waitingBoth: "準備してください"
+ ready: "準備完了"
+ cancelReady: "準備を再開"
+ opponentTurn: "相手のターンです"
+ myTurn: "あなたのターンです"
+ turnOf: "{name}のターンです"
+ pastTurnOf: "{name}のターン"
+ surrender: "投了"
+ surrendered: "投了により"
+ drawn: "引き分け"
+ won: "{name}の勝ち"
+ black: "黒"
+ white: "白"
+ total: "合計"
+ turnCount: "{count}ターン目"
+ myGames: "自分の対局"
+ allGames: "みんなの対局"
+ ended: "終了"
+ playing: "対局中"
+ isLlotheo: "石の少ない方が勝ち(ロセオ)"
+ loopedMap: "ループマップ"
+ canPutEverywhere: "どこでも置けるモード"
+ freeMatch: "フリーマッチ"
+ lookingForPlayer: "対戦相手を探しています"
diff --git a/packages/backend/migration/1705475608437-reversi.js b/packages/backend/migration/1705475608437-reversi.js
new file mode 100644
index 0000000000..c9d69e2c7c
--- /dev/null
+++ b/packages/backend/migration/1705475608437-reversi.js
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Reversi1705475608437 {
+ name = 'Reversi1705475608437'
+
+ async up(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_b46ec40746efceac604142be1c"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_b604d92d6c7aec38627f6eaf16"`);
+ await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "createdAt"`);
+ await queryRunner.query(`ALTER TABLE "reversi_matching" DROP COLUMN "createdAt"`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "reversi_matching" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL`);
+ await queryRunner.query(`ALTER TABLE "reversi_game" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL`);
+ await queryRunner.query(`CREATE INDEX "IDX_b604d92d6c7aec38627f6eaf16" ON "reversi_matching" ("createdAt") `);
+ await queryRunner.query(`CREATE INDEX "IDX_b46ec40746efceac604142be1c" ON "reversi_game" ("createdAt") `);
+ }
+}
diff --git a/packages/backend/migration/1705654039457-reversi-2.js b/packages/backend/migration/1705654039457-reversi-2.js
new file mode 100644
index 0000000000..33747ba9f7
--- /dev/null
+++ b/packages/backend/migration/1705654039457-reversi-2.js
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Reversi21705654039457 {
+ name = 'Reversi21705654039457'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user1Accepted" TO "user1Ready"`);
+ await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user2Accepted" TO "user2Ready"`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user1Ready" TO "user1Accepted"`);
+ await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user2Ready" TO "user2Accepted"`);
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 5ab476295c..f8e82c5a1c 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -107,6 +107,7 @@
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
+ "crc-32": "^1.2.2",
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"fastify": "4.24.3",
@@ -133,6 +134,7 @@
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
+ "misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.1",
"nanoid": "5.0.4",
"nested-property": "4.0.0",
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)));
+ }
+}
+
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index e29fee3f96..73de01f33a 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -79,5 +79,6 @@ export const DI = {
flashLikesRepository: Symbol('flashLikesRepository'),
userMemosRepository: Symbol('userMemosRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
+ reversiGamesRepository: Symbol('reversiGamesRepository'),
//#endregion
};
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 176978d35f..b4f0541712 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -39,6 +39,7 @@ import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
import { packedSigninSchema } from '@/models/json-schema/signin.js';
import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js';
import { packedAdSchema } from '@/models/json-schema/ad.js';
+import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js';
export const refs = {
UserLite: packedUserLiteSchema,
@@ -78,6 +79,8 @@ export const refs = {
Signin: packedSigninSchema,
RoleLite: packedRoleLiteSchema,
Role: packedRoleSchema,
+ ReversiGameLite: packedReversiGameLiteSchema,
+ ReversiGameDetailed: packedReversiGameDetailedSchema,
};
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 0399536c3e..2b2aaeb91c 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -5,7 +5,7 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
-import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord } from './_.js';
+import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame } from './_.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@@ -399,12 +399,18 @@ const $userMemosRepository: Provider = {
inject: [DI.db],
};
-export const $bubbleGameRecordsRepository: Provider = {
+const $bubbleGameRecordsRepository: Provider = {
provide: DI.bubbleGameRecordsRepository,
useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord),
inject: [DI.db],
};
+const $reversiGamesRepository: Provider = {
+ provide: DI.reversiGamesRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiReversiGame),
+ inject: [DI.db],
+};
+
@Module({
imports: [
],
@@ -475,6 +481,7 @@ export const $bubbleGameRecordsRepository: Provider = {
$flashLikesRepository,
$userMemosRepository,
$bubbleGameRecordsRepository,
+ $reversiGamesRepository,
],
exports: [
$usersRepository,
@@ -543,6 +550,7 @@ export const $bubbleGameRecordsRepository: Provider = {
$flashLikesRepository,
$userMemosRepository,
$bubbleGameRecordsRepository,
+ $reversiGamesRepository,
],
})
export class RepositoryModule {}
diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts
new file mode 100644
index 0000000000..d297d1f01d
--- /dev/null
+++ b/packages/backend/src/models/ReversiGame.ts
@@ -0,0 +1,127 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+
+@Entity('reversi_game')
+export class MiReversiGame {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Column('timestamp with time zone', {
+ nullable: true,
+ comment: 'The started date of the ReversiGame.',
+ })
+ public startedAt: Date | null;
+
+ @Column(id())
+ public user1Id: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user1: MiUser | null;
+
+ @Column(id())
+ public user2Id: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user2: MiUser | null;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public user1Ready: boolean;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public user2Ready: boolean;
+
+ /**
+ * どちらのプレイヤーが先行(黒)か
+ * 1 ... user1
+ * 2 ... user2
+ */
+ @Column('integer', {
+ nullable: true,
+ })
+ public black: number | null;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public isStarted: boolean;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public isEnded: boolean;
+
+ @Column({
+ ...id(),
+ nullable: true,
+ })
+ public winnerId: MiUser['id'] | null;
+
+ @Column({
+ ...id(),
+ nullable: true,
+ })
+ public surrendered: MiUser['id'] | null;
+
+ @Column('jsonb', {
+ default: [],
+ })
+ public logs: {
+ at: number;
+ color: boolean;
+ pos: number;
+ }[];
+
+ @Column('varchar', {
+ array: true, length: 64,
+ })
+ public map: string[];
+
+ @Column('varchar', {
+ length: 32,
+ })
+ public bw: string;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public isLlotheo: boolean;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public canPutEverywhere: boolean;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public loopedBoard: boolean;
+
+ @Column('jsonb', {
+ nullable: true, default: null,
+ })
+ public form1: any | null;
+
+ @Column('jsonb', {
+ nullable: true, default: null,
+ })
+ public form2: any | null;
+
+ /**
+ * ログのposを文字列としてすべて連結したもののCRC32値
+ */
+ @Column('varchar', {
+ length: 32, nullable: true,
+ })
+ public crc32: string | null;
+}
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index a1c4b0743e..a1a0d8823d 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -69,6 +69,8 @@ import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
+import { MiReversiGame } from '@/models/ReversiGame.js';
+
import type { Repository } from 'typeorm';
export {
@@ -138,6 +140,7 @@ export {
MiFlashLike,
MiUserMemo,
MiBubbleGameRecord,
+ MiReversiGame,
};
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport>;
@@ -206,3 +209,4 @@ export type FlashsRepository = Repository<MiFlash>;
export type FlashLikesRepository = Repository<MiFlashLike>;
export type UserMemoRepository = Repository<MiUserMemo>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord>;
+export type ReversiGamesRepository = Repository<MiReversiGame>;
diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts
new file mode 100644
index 0000000000..0d23b9dc79
--- /dev/null
+++ b/packages/backend/src/models/json-schema/reversi-game.ts
@@ -0,0 +1,234 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedReversiGameLiteSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ createdAt: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'date-time',
+ },
+ startedAt: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'date-time',
+ },
+ isStarted: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ isEnded: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ form1: {
+ type: 'any',
+ optional: false, nullable: true,
+ },
+ form2: {
+ type: 'any',
+ optional: false, nullable: true,
+ },
+ user1Ready: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ user2Ready: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ user1Id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ user2Id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ user1: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'User',
+ },
+ user2: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'User',
+ },
+ winnerId: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'id',
+ },
+ winner: {
+ type: 'object',
+ optional: false, nullable: true,
+ ref: 'User',
+ },
+ surrendered: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'id',
+ },
+ black: {
+ type: 'number',
+ optional: false, nullable: true,
+ },
+ bw: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ isLlotheo: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ canPutEverywhere: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ loopedBoard: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ },
+} as const;
+
+export const packedReversiGameDetailedSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ createdAt: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'date-time',
+ },
+ startedAt: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'date-time',
+ },
+ isStarted: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ isEnded: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ form1: {
+ type: 'any',
+ optional: false, nullable: true,
+ },
+ form2: {
+ type: 'any',
+ optional: false, nullable: true,
+ },
+ user1Ready: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ user2Ready: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ user1Id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ user2Id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ user1: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'User',
+ },
+ user2: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'User',
+ },
+ winnerId: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'id',
+ },
+ winner: {
+ type: 'object',
+ optional: false, nullable: true,
+ ref: 'User',
+ },
+ surrendered: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'id',
+ },
+ black: {
+ type: 'number',
+ optional: false, nullable: true,
+ },
+ bw: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ isLlotheo: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ canPutEverywhere: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ loopedBoard: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ logs: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ 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: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ },
+ },
+} as const;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 0430e9ca19..1e063c8673 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -77,6 +77,7 @@ import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserMemo } from '@/models/UserMemo.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
+import { MiReversiGame } from '@/models/ReversiGame.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
@@ -192,6 +193,7 @@ export const entities = [
MiFlashLike,
MiUserMemo,
MiBubbleGameRecord,
+ MiReversiGame,
...charts,
];
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index fa81380f01..aed352d15e 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -22,9 +22,13 @@ import { SigninApiService } from './api/SigninApiService.js';
import { SigninService } from './api/SigninService.js';
import { SignupApiService } from './api/SignupApiService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
+import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js';
+import { ClientLoggerService } from './web/ClientLoggerService.js';
+import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
+
import { MainChannelService } from './api/stream/channels/main.js';
import { AdminChannelService } from './api/stream/channels/admin.js';
import { AntennaChannelService } from './api/stream/channels/antenna.js';
@@ -38,10 +42,9 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js';
-import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
-import { ClientLoggerService } from './web/ClientLoggerService.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
-import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
+import { ReversiChannelService } from './api/stream/channels/reversi.js';
+import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
@Module({
imports: [
@@ -77,6 +80,8 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
GlobalTimelineChannelService,
HashtagChannelService,
RoleTimelineChannelService,
+ ReversiChannelService,
+ ReversiGameChannelService,
HomeTimelineChannelService,
HybridTimelineChannelService,
LocalTimelineChannelService,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 781332d349..df69ce2385 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -366,6 +366,12 @@ import * as ep___fetchExternalResources from './endpoints/fetch-external-resourc
import * as ep___retention from './endpoints/retention.js';
import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js';
import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js';
+import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js';
+import * as ep___reversi_games from './endpoints/reversi/games.js';
+import * as ep___reversi_match from './endpoints/reversi/match.js';
+import * as ep___reversi_invitations from './endpoints/reversi/invitations.js';
+import * as ep___reversi_showGame from './endpoints/reversi/show-game.js';
+import * as ep___reversi_surrender from './endpoints/reversi/surrender.js';
import { GetterService } from './GetterService.js';
import { ApiLoggerService } from './ApiLoggerService.js';
import type { Provider } from '@nestjs/common';
@@ -730,6 +736,12 @@ const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resource
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
const $bubbleGame_register: Provider = { provide: 'ep:bubble-game/register', useClass: ep___bubbleGame_register.default };
const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useClass: ep___bubbleGame_ranking.default };
+const $reversi_cancelMatch: Provider = { provide: 'ep:reversi/cancel-match', useClass: ep___reversi_cancelMatch.default };
+const $reversi_games: Provider = { provide: 'ep:reversi/games', useClass: ep___reversi_games.default };
+const $reversi_match: Provider = { provide: 'ep:reversi/match', useClass: ep___reversi_match.default };
+const $reversi_invitations: Provider = { provide: 'ep:reversi/invitations', useClass: ep___reversi_invitations.default };
+const $reversi_showGame: Provider = { provide: 'ep:reversi/show-game', useClass: ep___reversi_showGame.default };
+const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass: ep___reversi_surrender.default };
@Module({
imports: [
@@ -1098,6 +1110,12 @@ const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useCl
$retention,
$bubbleGame_register,
$bubbleGame_ranking,
+ $reversi_cancelMatch,
+ $reversi_games,
+ $reversi_match,
+ $reversi_invitations,
+ $reversi_showGame,
+ $reversi_surrender,
],
exports: [
$admin_meta,
@@ -1457,6 +1475,12 @@ const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useCl
$retention,
$bubbleGame_register,
$bubbleGame_ranking,
+ $reversi_cancelMatch,
+ $reversi_games,
+ $reversi_match,
+ $reversi_invitations,
+ $reversi_showGame,
+ $reversi_surrender,
],
})
export class EndpointsModule {}
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index f17db41a5d..0f2c8cb754 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -367,6 +367,12 @@ import * as ep___fetchExternalResources from './endpoints/fetch-external-resourc
import * as ep___retention from './endpoints/retention.js';
import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js';
import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js';
+import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js';
+import * as ep___reversi_games from './endpoints/reversi/games.js';
+import * as ep___reversi_match from './endpoints/reversi/match.js';
+import * as ep___reversi_invitations from './endpoints/reversi/invitations.js';
+import * as ep___reversi_showGame from './endpoints/reversi/show-game.js';
+import * as ep___reversi_surrender from './endpoints/reversi/surrender.js';
const eps = [
['admin/meta', ep___admin_meta],
@@ -729,6 +735,12 @@ const eps = [
['retention', ep___retention],
['bubble-game/register', ep___bubbleGame_register],
['bubble-game/ranking', ep___bubbleGame_ranking],
+ ['reversi/cancel-match', ep___reversi_cancelMatch],
+ ['reversi/games', ep___reversi_games],
+ ['reversi/match', ep___reversi_match],
+ ['reversi/invitations', ep___reversi_invitations],
+ ['reversi/show-game', ep___reversi_showGame],
+ ['reversi/surrender', ep___reversi_surrender],
];
interface IEndpointMetaBase {
diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts
index 7ff7b5de3a..2d853b94f3 100644
--- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts
+++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts
@@ -73,7 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// Get mutee
- const mutee = await getterService.getUser(ps.userId).catch(err => {
+ const mutee = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
diff --git a/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts b/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts
new file mode 100644
index 0000000000..8edc049500
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts
@@ -0,0 +1,44 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiService } from '@/core/ReversiService.js';
+
+export const meta = {
+ requireCredential: true,
+
+ kind: 'write:account',
+
+ errors: {
+ },
+
+ res: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ userId: { type: 'string', format: 'misskey:id', nullable: true },
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private reversiService: ReversiService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ if (ps.userId) {
+ await this.reversiService.matchSpecificUserCancel(me, ps.userId);
+ return;
+ } else {
+ await this.reversiService.matchAnyUserCancel(me);
+ }
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/games.ts b/packages/backend/src/server/api/endpoints/reversi/games.ts
new file mode 100644
index 0000000000..5322cd0987
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/games.ts
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
+import { DI } from '@/di-symbols.js';
+import type { ReversiGamesRepository } from '@/models/_.js';
+import { QueryService } from '@/core/QueryService.js';
+
+export const meta = {
+ requireCredential: false,
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: { ref: 'ReversiGameLite' },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ my: { type: 'boolean', default: false },
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.reversiGamesRepository)
+ private reversiGamesRepository: ReversiGamesRepository,
+
+ private reversiGameEntityService: ReversiGameEntityService,
+ private queryService: QueryService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
+ .andWhere('game.isStarted = TRUE');
+
+ if (ps.my && me) {
+ query.andWhere(new Brackets(qb => {
+ qb
+ .where('game.user1Id = :userId', { userId: me.id })
+ .orWhere('game.user2Id = :userId', { userId: me.id });
+ }));
+ }
+
+ const games = await query.take(ps.limit).getMany();
+
+ return await this.reversiGameEntityService.packLiteMany(games, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/invitations.ts b/packages/backend/src/server/api/endpoints/reversi/invitations.ts
new file mode 100644
index 0000000000..0b7107bb0d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/invitations.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ReversiService } from '@/core/ReversiService.js';
+
+export const meta = {
+ requireCredential: true,
+
+ kind: 'read:account',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: { ref: 'UserLite' },
+ },
+} as const;
+
+export const paramDef = {
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private userEntityService: UserEntityService,
+ private reversiService: ReversiService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const invitations = await this.reversiService.getInvitations(me);
+
+ return await this.userEntityService.packMany(invitations, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/match.ts b/packages/backend/src/server/api/endpoints/reversi/match.ts
new file mode 100644
index 0000000000..da5a3409ef
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/match.ts
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiService } from '@/core/ReversiService.js';
+import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
+import { ApiError } from '../../error.js';
+import { GetterService } from '../../GetterService.js';
+
+export const meta = {
+ requireCredential: true,
+
+ kind: 'write:account',
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '0b4f0559-b484-4e31-9581-3f73cee89b28',
+ },
+
+ isYourself: {
+ message: 'Target user is yourself.',
+ code: 'TARGET_IS_YOURSELF',
+ id: '96fd7bd6-d2bc-426c-a865-d055dcd2828e',
+ },
+ },
+
+ res: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ userId: { type: 'string', format: 'misskey:id', nullable: true },
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private getterService: GetterService,
+ private reversiService: ReversiService,
+ private reversiGameEntityService: ReversiGameEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ if (ps.userId === me.id) throw new ApiError(meta.errors.isYourself);
+
+ const target = ps.userId ? await this.getterService.getUser(ps.userId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ }) : null;
+
+ const game = target ? await this.reversiService.matchSpecificUser(me, target) : await this.reversiService.matchAnyUser(me);
+
+ if (game == null) return;
+
+ return await this.reversiGameEntityService.packDetail(game, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/show-game.ts b/packages/backend/src/server/api/endpoints/reversi/show-game.ts
new file mode 100644
index 0000000000..de571053e1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/show-game.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiService } from '@/core/ReversiService.js';
+import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ requireCredential: false,
+
+ errors: {
+ noSuchGame: {
+ message: 'No such game.',
+ code: 'NO_SUCH_GAME',
+ id: 'f13a03db-fae1-46c9-87f3-43c8165419e1',
+ },
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ReversiGameDetailed',
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ gameId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['gameId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private reversiService: ReversiService,
+ private reversiGameEntityService: ReversiGameEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const game = await this.reversiService.get(ps.gameId);
+
+ if (game == null) {
+ throw new ApiError(meta.errors.noSuchGame);
+ }
+
+ return await this.reversiGameEntityService.packDetail(game, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/reversi/surrender.ts b/packages/backend/src/server/api/endpoints/reversi/surrender.ts
new file mode 100644
index 0000000000..c47d36be33
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/reversi/surrender.ts
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ReversiService } from '@/core/ReversiService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ requireCredential: true,
+
+ kind: 'write:account',
+
+ errors: {
+ noSuchGame: {
+ message: 'No such game.',
+ code: 'NO_SUCH_GAME',
+ id: 'ace0b11f-e0a6-4076-a30d-e8284c81b2df',
+ },
+
+ alreadyEnded: {
+ message: 'That game has already ended.',
+ code: 'ALREADY_ENDED',
+ id: '6c2ad4a6-cbf1-4a5b-b187-b772826cfc6d',
+ },
+
+ accessDenied: {
+ message: 'Access denied.',
+ code: 'ACCESS_DENIED',
+ id: '6e04164b-a992-4c93-8489-2123069973e1',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ gameId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['gameId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private reversiService: ReversiService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const game = await this.reversiService.get(ps.gameId);
+
+ if (game == null) {
+ throw new ApiError(meta.errors.noSuchGame);
+ }
+
+ if (game.isEnded) {
+ throw new ApiError(meta.errors.alreadyEnded);
+ }
+
+ if ((game.user1Id !== me.id) && (game.user2Id !== me.id)) {
+ throw new ApiError(meta.errors.accessDenied);
+ }
+
+ await this.reversiService.surrender(game, me);
+ });
+ }
+}
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<false> {
+ 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<true> {
+ 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,
+ );
+ }
+}
diff --git a/packages/frontend/assets/reversi/logo.png b/packages/frontend/assets/reversi/logo.png
new file mode 100644
index 0000000000..7d807ef1dc
--- /dev/null
+++ b/packages/frontend/assets/reversi/logo.png
Binary files differ
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 8c3ce30668..a9a68601fc 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -41,6 +41,7 @@
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.1.0",
"compare-versions": "6.1.0",
+ "crc-32": "^1.2.2",
"cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0",
"escape-regexp": "0.0.1",
@@ -53,6 +54,7 @@
"matter-js": "0.19.0",
"mfm-js": "0.24.0",
"misskey-js": "workspace:*",
+ "misskey-reversi": "workspace:*",
"photoswipe": "5.4.3",
"punycode": "2.3.1",
"rollup": "4.9.1",
diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue
index d9178f3362..22e7ed1ef7 100644
--- a/packages/frontend/src/components/MkRadios.vue
+++ b/packages/frontend/src/components/MkRadios.vue
@@ -18,6 +18,9 @@ export default defineComponent({
watch(value, () => {
context.emit('update:modelValue', value.value);
});
+ watch(() => props.modelValue, v => {
+ value.value = v;
+ });
if (!context.slots.default) return null;
let options = context.slots.default();
const label = context.slots.label && context.slots.label();
diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue
index 33b8a9a86d..16416fd2e4 100644
--- a/packages/frontend/src/components/MkSelect.vue
+++ b/packages/frontend/src/components/MkSelect.vue
@@ -52,7 +52,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
- (ev: 'change', _ev: KeyboardEvent): void;
+ (ev: 'changeByUser'): void;
(ev: 'update:modelValue', value: string | null): void;
}>();
@@ -77,7 +77,6 @@ const height =
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
changed.value = true;
- emit('change', ev);
};
const updated = () => {
@@ -136,6 +135,7 @@ function show(ev: MouseEvent) {
active: computed(() => v.value === option.props.value),
action: () => {
v.value = option.props.value;
+ emit('changeByUser', v.value);
},
});
};
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index f4aa06950d..ad11ba1940 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -85,7 +85,7 @@ const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
const selected = ref<Misskey.entities.UserDetailed | null>(null);
const dialogEl = ref();
-const search = () => {
+function search() {
if (username.value === '' && host.value === '') {
users.value = [];
return;
@@ -98,9 +98,9 @@ const search = () => {
}).then(_users => {
users.value = _users;
});
-};
+}
-const ok = () => {
+function ok() {
if (selected.value == null) return;
emit('ok', selected.value);
dialogEl.value.close();
@@ -110,12 +110,12 @@ const ok = () => {
recents = recents.filter(x => x !== selected.value.id);
recents.unshift(selected.value.id);
defaultStore.set('recentlyUsedUsers', recents.splice(0, 16));
-};
+}
-const cancel = () => {
+function cancel() {
emit('cancel');
dialogEl.value.close();
-};
+}
onMounted(() => {
misskeyApi('users/show', {
diff --git a/packages/frontend/src/global/router/definition.ts b/packages/frontend/src/global/router/definition.ts
index 8e1c178ea2..0333770a64 100644
--- a/packages/frontend/src/global/router/definition.ts
+++ b/packages/frontend/src/global/router/definition.ts
@@ -15,6 +15,7 @@ const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
loadingComponent: MkLoading,
errorComponent: MkError,
});
+
const routes = [{
path: '/@:initUser/pages/:initPageName/view-source',
component: page(() => import('@/pages/page-editor/page-editor.vue')),
@@ -529,18 +530,26 @@ const routes = [{
component: page(() => import('@/pages/antenna-timeline.vue')),
loginRequired: true,
}, {
- path: '/games',
- component: page(() => import('@/pages/games.vue')),
- loginRequired: true,
-}, {
path: '/clicker',
component: page(() => import('@/pages/clicker.vue')),
loginRequired: true,
}, {
+ path: '/games',
+ component: page(() => import('@/pages/games.vue')),
+ loginRequired: false,
+}, {
path: '/bubble-game',
component: page(() => import('@/pages/drop-and-fusion.vue')),
loginRequired: true,
}, {
+ path: '/reversi',
+ component: page(() => import('@/pages/reversi/index.vue')),
+ loginRequired: false,
+}, {
+ path: '/reversi/g/:gameId',
+ component: page(() => import('@/pages/reversi/game.vue')),
+ loginRequired: false,
+}, {
path: '/timeline',
component: page(() => import('@/pages/timeline.vue')),
}, {
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index a63d61bb8f..9fc3603af0 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -419,7 +419,7 @@ export function form(title, form) {
});
}
-export async function selectUser(opts: { includeSelf?: boolean } = {}) {
+export async function selectUser(opts: { includeSelf?: boolean } = {}): Promise<Misskey.entities.UserLite> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
includeSelf: opts.includeSelf,
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index dd3b189c9d..beb2e714e0 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -123,7 +123,7 @@ function onGameEnd() {
definePageMetadata({
title: i18n.ts.bubbleGame,
- icon: 'ti ti-apple',
+ icon: 'ti ti-device-gamepad',
});
</script>
diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue
index 5d2482ded1..45a135a459 100644
--- a/packages/frontend/src/pages/games.vue
+++ b/packages/frontend/src/pages/games.vue
@@ -7,10 +7,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
- <div class="_panel">
- <MkA to="/bubble-game">
- <img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
- </MkA>
+ <div class="_gaps">
+ <div class="_panel">
+ <MkA to="/bubble-game">
+ <img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
+ </MkA>
+ </div>
+ <div class="_panel">
+ <MkA to="/reversi">
+ <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
+ </MkA>
+ </div>
</div>
</MkSpacer>
</MkStickyContainer>
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
new file mode 100644
index 0000000000..18fd74427c
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -0,0 +1,428 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkSpacer :contentMax="600">
+ <div :class="$style.root" class="_gaps">
+ <header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ i18n.ts._reversi.black }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ i18n.ts._reversi.white }})</header>
+
+ <div style="overflow: clip; line-height: 28px;">
+ <div v-if="!iAmPlayer && !game.isEnded && turnUser" class="turn">
+ <Mfm :key="'turn:' + turnUser.id" :text="i18n.t('_reversi.turnOf', { name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/>
+ <MkEllipsis/>
+ </div>
+ <div v-if="(logPos !== logs.length) && turnUser" class="turn">
+ <Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.t('_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">
+ <template v-if="game.winner">
+ <Mfm :key="'won'" :text="i18n.t('_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>
+ </template>
+ <template v-else>{{ i18n.ts._reversi.drawn }}</template>
+ </div>
+ </div>
+
+ <div :class="$style.board">
+ <div v-if="showBoardLabels" :class="$style.labelsX">
+ <span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
+ </div>
+ <div style="display: flex;">
+ <div v-if="showBoardLabels" :class="$style.labelsY">
+ <div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
+ </div>
+ <div :class="$style.boardCells" :style="cellsStyle">
+ <div
+ v-for="(stone, i) in engine.board"
+ v-tooltip="`${String.fromCharCode(65 + engine.posToXy(i)[0])}${engine.posToXy(i)[1] + 1}`"
+ :class="[$style.boardCell, {
+ [$style.boardCell_empty]: stone == null,
+ [$style.boardCell_none]: engine.map[i] === 'null',
+ [$style.boardCell_isEnded]: game.isEnded,
+ [$style.boardCell_myTurn]: !game.isEnded && isMyTurn,
+ [$style.boardCell_can]: turnUser ? engine.canPut(turnUser.id === blackUser.id, i) : null,
+ [$style.boardCell_prev]: engine.prevPos === i
+ }]"
+ @click="putStone(i)"
+ >
+ <img v-if="stone === true" style="pointer-events: none; user-select: none; display: block; width: 100%; height: 100%;" :src="blackUser.avatarUrl">
+ <img v-if="stone === false" style="pointer-events: none; user-select: none; display: block; width: 100%; height: 100%;" :src="whiteUser.avatarUrl">
+ </div>
+ </div>
+ <div v-if="showBoardLabels" :class="$style.labelsY">
+ <div v-for="i in game.map.length" :class="$style.labelsYLabel">{{ i }}</div>
+ </div>
+ </div>
+ <div v-if="showBoardLabels" :class="$style.labelsX">
+ <span v-for="i in game.map[0].length" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
+ </div>
+ </div>
+
+ <div class="status"><b>{{ i18n.t('_reversi.turnCount', { count: logPos }) }}</b> {{ i18n.ts._reversi.black }}:{{ engine.blackCount }} {{ i18n.ts._reversi.white }}:{{ engine.whiteCount }} {{ i18n.ts._reversi.total }}:{{ engine.blackCount + engine.whiteCount }}</div>
+
+ <div v-if="!game.isEnded && iAmPlayer" class="_buttonsCenter">
+ <MkButton danger @click="surrender">{{ i18n.ts._reversi.surrender }}</MkButton>
+ </div>
+
+ <div v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;">
+ <div>{{ logPos }} / {{ 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>
+ </div>
+ <MkButton style="margin: auto;" :disabled="autoplaying" @click="autoplay()"><i class="ti ti-player-play"></i></MkButton>
+ </div>
+
+ <div>
+ <p v-if="game.isLlotheo">{{ i18n.ts._reversi.isLlotheo }}</p>
+ <p v-if="game.loopedBoard">{{ i18n.ts._reversi.loopedMap }}</p>
+ <p v-if="game.canPutEverywhere">{{ i18n.ts._reversi.canPutEverywhere }}</p>
+ </div>
+
+ <MkA v-if="game.isEnded" :to="`/reversi`">
+ <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; width: 200px; margin: auto;"/>
+ </MkA>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue';
+import * as CRC32 from 'crc-32';
+import * as Misskey from 'misskey-js';
+import * as Reversi from 'misskey-reversi';
+import MkButton from '@/components/MkButton.vue';
+import { deepClone } from '@/scripts/clone.js';
+import { useInterval } from '@/scripts/use-interval.js';
+import { signinRequired } from '@/account.js';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { userPage } from '@/filters/user.js';
+
+const $i = signinRequired();
+
+const props = defineProps<{
+ game: Misskey.entities.ReversiGameDetailed;
+ connection: Misskey.ChannelConnection;
+}>();
+
+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, {
+ isLlotheo: game.value.isLlotheo,
+ canPutEverywhere: game.value.canPutEverywhere,
+ loopedBoard: game.value.loopedBoard,
+}));
+
+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;
+});
+
+const myColor = computed(() => {
+ if (!iAmPlayer.value) return null;
+ if (game.value.user1Id === $i.id && game.value.black === 1) return true;
+ if (game.value.user2Id === $i.id && game.value.black === 2) return true;
+ return false;
+});
+
+const opColor = computed(() => {
+ if (!iAmPlayer.value) return null;
+ return !myColor.value;
+});
+
+const blackUser = computed(() => {
+ return game.value.black === 1 ? game.value.user1 : game.value.user2;
+});
+
+const whiteUser = computed(() => {
+ return game.value.black === 1 ? game.value.user2 : game.value.user1;
+});
+
+const turnUser = computed(() => {
+ if (engine.value.turn === true) {
+ return game.value.black === 1 ? game.value.user1 : game.value.user2;
+ } else if (engine.value.turn === false) {
+ return game.value.black === 1 ? game.value.user2 : game.value.user1;
+ } else {
+ return null;
+ }
+});
+
+const isMyTurn = computed(() => {
+ if (!iAmPlayer.value) return false;
+ const u = turnUser.value;
+ if (u == null) return false;
+ return u.id === $i.id;
+});
+
+const cellsStyle = computed(() => {
+ return {
+ 'grid-template-rows': `repeat(${game.value.map.length}, 1fr)`,
+ 'grid-template-columns': `repeat(${game.value.map[0].length}, 1fr)`,
+ };
+});
+
+watch(logPos, (v) => {
+ if (!game.value.isEnded) return;
+ const _o = new Reversi.Game(game.value.map, {
+ isLlotheo: game.value.isLlotheo,
+ canPutEverywhere: game.value.canPutEverywhere,
+ loopedBoard: game.value.loopedBoard,
+ });
+ 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(''));
+ props.connection.send('syncState', {
+ crc32: crc32,
+ });
+ }, 5000, { immediate: false, afterMounted: true });
+}
+
+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);
+ triggerRef(engine);
+
+ // サウンドを再生する
+ //sound.play(myColor.value ? 'reversiPutBlack' : 'reversiPutWhite');
+
+ props.connection.send('putStone', {
+ pos: pos,
+ });
+
+ checkEnd();
+}
+
+function onPutStone(x) {
+ logs.value.push(x);
+ logPos.value++;
+ engine.value.put(x.color, x.pos);
+ triggerRef(engine);
+ checkEnd();
+
+ // サウンドを再生する
+ if (x.color !== myColor.value) {
+ //sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
+ }
+}
+
+function onEnded(x) {
+ game.value = deepClone(x.game);
+}
+
+function checkEnd() {
+ game.value.isEnded = engine.value.isEnded;
+ if (game.value.isEnded) {
+ if (engine.value.winner === true) {
+ game.value.winnerId = game.value.black === 1 ? game.value.user1Id : game.value.user2Id;
+ game.value.winner = game.value.black === 1 ? game.value.user1 : game.value.user2;
+ } else if (engine.value.winner === false) {
+ game.value.winnerId = game.value.black === 1 ? game.value.user2Id : game.value.user1Id;
+ game.value.winner = game.value.black === 1 ? game.value.user2 : game.value.user1;
+ } else {
+ game.value.winnerId = null;
+ game.value.winner = null;
+ }
+ }
+}
+
+function onRescue(_game) {
+ game.value = deepClone(_game);
+
+ engine.value = new Reversi.Game(game.value.map, {
+ isLlotheo: game.value.isLlotheo,
+ canPutEverywhere: game.value.canPutEverywhere,
+ loopedBoard: game.value.loopedBoard,
+ });
+
+ 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;
+
+ checkEnd();
+}
+
+function surrender() {
+ misskeyApi('reversi/surrender', {
+ gameId: game.value.id,
+ });
+}
+
+function autoplay() {
+ autoplaying.value = true;
+ logPos.value = 0;
+
+ window.setTimeout(() => {
+ logPos.value = 1;
+
+ let i = 1;
+ let previousLog = game.value.logs[0];
+ const tick = () => {
+ const log = game.value.logs[i];
+ const time = new Date(log.at).getTime() - new Date(previousLog.at).getTime();
+ setTimeout(() => {
+ i++;
+ logPos.value++;
+ previousLog = log;
+
+ if (i < game.value.logs.length) {
+ tick();
+ } else {
+ autoplaying.value = false;
+ }
+ }, time);
+ };
+
+ tick();
+ }, 1000);
+}
+
+onMounted(() => {
+ props.connection.on('putStone', onPutStone);
+ props.connection.on('rescue', onRescue);
+ props.connection.on('ended', onEnded);
+});
+
+onUnmounted(() => {
+ props.connection.off('putStone', onPutStone);
+ props.connection.off('rescue', onRescue);
+ props.connection.off('ended', onEnded);
+});
+</script>
+
+<style lang="scss" module>
+@use "sass:math";
+
+$label-size: 16px;
+$gap: 4px;
+
+.root {
+ text-align: center;
+}
+
+.board {
+ width: calc(100% - 16px);
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+.labelsX {
+ height: $label-size;
+ padding: 0 $label-size;
+ display: flex;
+}
+
+.labelsXLabel {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.8em;
+
+ &:first-child {
+ margin-left: -(math.div($gap, 2));
+ }
+
+ &:last-child {
+ margin-right: -(math.div($gap, 2));
+ }
+}
+
+.labelsY {
+ width: $label-size;
+ display: flex;
+ flex-direction: column;
+}
+
+.labelsYLabel {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+
+ &:first-child {
+ margin-top: -(math.div($gap, 2));
+ }
+
+ &:last-child {
+ margin-bottom: -(math.div($gap, 2));
+ }
+}
+
+.boardCells {
+ flex: 1;
+ display: grid;
+ grid-gap: $gap;
+}
+
+.boardCell {
+ background: transparent;
+ border-radius: 6px;
+ overflow: clip;
+
+ &.boardCell_empty {
+ border: solid 2px var(--divider);
+ }
+
+ &.boardCell_empty.boardCell_can {
+ border-color: var(--accent);
+ opacity: 0.5;
+ }
+
+ &.boardCell_empty.boardCell_myTurn {
+ border-color: var(--divider);
+ opacity: 1;
+
+ &.boardCell_can {
+ border-color: var(--accent);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--accent);
+ }
+ }
+ }
+
+ &.boardCell_prev {
+ box-shadow: 0 0 0 4px var(--accent);
+ }
+
+ &.boardCell_isEnded {
+ border-color: var(--divider);
+ }
+
+ &.boardCell_none {
+ border-color: transparent !important;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue
new file mode 100644
index 0000000000..301a177de1
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.setting.vue
@@ -0,0 +1,236 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <MkSpacer :contentMax="600">
+ <div style="text-align: center;"><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></div>
+
+ <div class="_gaps">
+ <div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div>
+
+ <div class="_panel">
+ <div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
+ <div>{{ mapName }}</div>
+ <MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
+ </div>
+
+ <div style="padding: 16px;">
+ <div v-if="game.map == null"><i class="ti ti-dice"></i></div>
+ <div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
+ <div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
+ <i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <MkFolder :defaultOpen="true">
+ <template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
+
+ <MkRadios v-model="game.bw">
+ <option value="random">{{ i18n.ts.random }}</option>
+ <option :value="'1'">
+ <I18n :src="i18n.ts._reversi.blackIs" tag="span">
+ <template #name>
+ <b><MkUserName :user="game.user1"/></b>
+ </template>
+ </I18n>
+ </option>
+ <option :value="'2'">
+ <I18n :src="i18n.ts._reversi.blackIs" tag="span">
+ <template #name>
+ <b><MkUserName :user="game.user2"/></b>
+ </template>
+ </I18n>
+ </option>
+ </MkRadios>
+ </MkFolder>
+
+ <MkFolder :defaultOpen="true">
+ <template #label>{{ i18n.ts._reversi.rules }}</template>
+
+ <div class="_gaps_s">
+ <MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
+ <MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
+ <MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
+ </div>
+ </MkFolder>
+ </div>
+ </MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
+ <div style="text-align: center; margin-bottom: 10px;">
+ <template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
+ <template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template>
+ <template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template>
+ <template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
+ </div>
+ <div class="_buttonsCenter">
+ <MkButton rounded danger @click="exit">{{ i18n.ts.cancel }}</MkButton>
+ <MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
+ <MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as Reversi from 'misskey-reversi';
+import { i18n } from '@/i18n.js';
+import { signinRequired } from '@/account.js';
+import { deepClone } from '@/scripts/clone.js';
+import MkButton from '@/components/MkButton.vue';
+import MkRadios from '@/components/MkRadios.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import * as os from '@/os.js';
+import { MenuItem } from '@/types/menu.js';
+
+const $i = signinRequired();
+
+const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category)));
+
+const props = defineProps<{
+ game: Misskey.entities.ReversiGameDetailed;
+ connection: Misskey.ChannelConnection;
+}>();
+
+const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
+const isLlotheo = ref<boolean>(false);
+const mapName = computed(() => {
+ if (game.value.map == null) return 'Random';
+ const found = Object.values(Reversi.maps).find(x => x.data.join('') === game.value.map.join(''));
+ return found ? found.name! : '-Custom-';
+});
+const isReady = computed(() => {
+ if (game.value.user1Id === $i.id && game.value.user1Ready) return true;
+ if (game.value.user2Id === $i.id && game.value.user2Ready) return true;
+ return false;
+});
+const isOpReady = computed(() => {
+ if (game.value.user1Id !== $i.id && game.value.user1Ready) return true;
+ if (game.value.user2Id !== $i.id && game.value.user2Ready) return true;
+ return false;
+});
+
+watch(() => game.value.bw, () => {
+ updateSettings('bw');
+});
+
+function chooseMap(ev: MouseEvent) {
+ const menu: MenuItem[] = [];
+
+ for (const c of mapCategories) {
+ const maps = Object.values(Reversi.maps).filter(x => x.category === c);
+ if (maps.length === 0) continue;
+ if (c != null) {
+ menu.push({
+ type: 'label',
+ text: c,
+ });
+ }
+ for (const m of maps) {
+ menu.push({
+ text: m.name!,
+ action: () => {
+ game.value.map = m.data;
+ updateSettings('map');
+ },
+ });
+ }
+ }
+
+ os.popupMenu(menu, ev.currentTarget ?? ev.target);
+}
+
+function exit() {
+ props.connection.send('exit', {});
+}
+
+function ready() {
+ props.connection.send('ready', true);
+}
+
+function unready() {
+ props.connection.send('ready', false);
+}
+
+function onChangeReadyStates(states) {
+ game.value.user1Ready = states.user1;
+ game.value.user2Ready = states.user2;
+}
+
+function updateSettings(key: keyof Misskey.entities.ReversiGameDetailed) {
+ props.connection.send('updateSettings', {
+ key: key,
+ value: game.value[key],
+ });
+}
+
+function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof Misskey.entities.ReversiGameDetailed; value: any; }) {
+ if (userId === $i.id) return;
+ if (game.value[key] === value) return;
+ game.value[key] = value;
+}
+
+function onMapCellClick(pos: number, pixel: string) {
+ const x = pos % game.value.map[0].length;
+ const y = Math.floor(pos / game.value.map[0].length);
+ const newPixel =
+ pixel === ' ' ? '-' :
+ pixel === '-' ? 'b' :
+ pixel === 'b' ? 'w' :
+ ' ';
+ const line = game.value.map[y].split('');
+ line[x] = newPixel;
+ game.value.map[y] = line.join('');
+ updateSettings('map');
+}
+
+props.connection.on('changeReadyStates', onChangeReadyStates);
+props.connection.on('updateSettings', onUpdateSettings);
+
+onUnmounted(() => {
+ props.connection.off('changeReadyStates', onChangeReadyStates);
+ props.connection.off('updateSettings', onUpdateSettings);
+});
+</script>
+
+<style lang="scss" module>
+.board {
+ display: grid;
+ grid-gap: 4px;
+ width: 300px;
+ height: 300px;
+ margin: 0 auto;
+ color: var(--fg);
+}
+
+.boardCell {
+ display: grid;
+ place-items: center;
+ background: transparent;
+ border: solid 2px var(--divider);
+ border-radius: 6px;
+ overflow: clip;
+ cursor: pointer;
+}
+.boardCellNone {
+ border-color: transparent;
+}
+
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ background: var(--acrylicBg);
+ border-top: solid 0.5px var(--divider);
+}
+</style>
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
new file mode 100644
index 0000000000..dbbeb20f42
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -0,0 +1,68 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+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"/>
+<GameBoard v-else :game="game" :connection="connection"/>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import GameSetting from './game.setting.vue';
+import GameBoard from './game.board.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { useStream } from '@/stream.js';
+
+const props = defineProps<{
+ gameId: string;
+}>();
+
+const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null);
+const connection = shallowRef<Misskey.ChannelConnection | null>(null);
+
+watch(() => props.gameId, () => {
+ fetchGame();
+});
+
+async function fetchGame() {
+ const _game = await misskeyApi('reversi/show-game', {
+ gameId: props.gameId,
+ });
+
+ game.value = _game;
+
+ if (connection.value) {
+ connection.value.dispose();
+ }
+ connection.value = useStream().useChannel('reversiGame', {
+ gameId: game.value.id,
+ });
+ connection.value.on('started', x => {
+ game.value = x.game;
+ });
+}
+
+onMounted(() => {
+ fetchGame();
+});
+
+onUnmounted(() => {
+ if (connection.value) {
+ connection.value.dispose();
+ }
+});
+
+const headerActions = computed(() => []);
+
+const headerTabs = computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: 'Reversi',
+ icon: 'ti ti-device-gamepad',
+})));
+</script>
diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue
new file mode 100644
index 0000000000..c483e36c24
--- /dev/null
+++ b/packages/frontend/src/pages/reversi/index.vue
@@ -0,0 +1,271 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkSpacer v-if="!matchingAny && !matchingUser" :contentMax="600">
+ <div class="_gaps">
+ <div>
+ <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
+ </div>
+
+ <div class="_buttonsCenter">
+ <MkButton primary gradate rounded @click="matchAny">{{ i18n.ts._reversi.freeMatch }}</MkButton>
+ <MkButton primary gradate rounded @click="matchUser">{{ i18n.ts.invite }}</MkButton>
+ </div>
+
+ <MkFolder v-if="invitations.length > 0" :defaultOpen="true">
+ <template #label>{{ i18n.ts.invitations }}</template>
+ <div class="_gaps_s">
+ <button v-for="user in invitations" :key="user.id" v-panel :class="$style.invitation" class="_button" tabindex="-1" @click="accept(user)">
+ <MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="user" :showIndicator="true"/>
+ <span style="margin-right: 8px;"><b><MkUserName :user="user"/></b></span>
+ <span>@{{ user.username }}</span>
+ </button>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="$i" :defaultOpen="true">
+ <template #label>{{ i18n.ts._reversi.myGames }}</template>
+ <MkPagination :pagination="myGamesPagination">
+ <template #default="{ items }">
+ <div :class="$style.gamePreviews">
+ <MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`">
+ <div :class="$style.gamePreviewPlayers">
+ <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
+ </div>
+ <div :class="$style.gamePreviewFooter">
+ <span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span>
+ <MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
+ </div>
+ </MkA>
+ </div>
+ </template>
+ </MkPagination>
+ </MkFolder>
+
+ <MkFolder :defaultOpen="true">
+ <template #label>{{ i18n.ts._reversi.allGames }}</template>
+ <MkPagination :pagination="gamesPagination">
+ <template #default="{ items }">
+ <div :class="$style.gamePreviews">
+ <MkA v-for="g in items" :key="g.id" v-panel :class="$style.gamePreview" tabindex="-1" :to="`/reversi/g/${g.id}`">
+ <div :class="$style.gamePreviewPlayers">
+ <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/>
+ </div>
+ <div :class="$style.gamePreviewFooter">
+ <span :style="!g.isEnded ? 'color: var(--accent);' : ''">{{ g.isEnded ? i18n.ts._reversi.ended : i18n.ts._reversi.playing }}</span>
+ <MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
+ </div>
+ </MkA>
+ </div>
+ </template>
+ </MkPagination>
+ </MkFolder>
+ </div>
+</MkSpacer>
+<MkSpacer v-else :contentMax="600">
+ <div :class="$style.waitingScreen">
+ <div v-if="matchingUser" :class="$style.waitingScreenTitle">
+ <I18n :src="i18n.ts.waitingFor" tag="span">
+ <template #x>
+ <b><MkUserName :user="matchingUser"/></b>
+ </template>
+ </I18n>
+ <MkEllipsis/>
+ </div>
+ <div v-else :class="$style.waitingScreenTitle">
+ {{ i18n.ts._reversi.lookingForPlayer }}<MkEllipsis/>
+ </div>
+ <div class="cancel">
+ <MkButton inline rounded @click="cancelMatching">{{ i18n.ts.cancel }}</MkButton>
+ </div>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { useStream } from '@/stream.js';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import { i18n } from '@/i18n.js';
+import { $i } from '@/account.js';
+import MkPagination from '@/components/MkPagination.vue';
+import { useRouter } from '@/global/router/supplier.js';
+import * as os from '@/os.js';
+import { useInterval } from '@/scripts/use-interval.js';
+
+const myGamesPagination = {
+ endpoint: 'reversi/games' as const,
+ limit: 10,
+ params: {
+ my: true,
+ },
+};
+
+const gamesPagination = {
+ endpoint: 'reversi/games' as const,
+ limit: 10,
+};
+
+const router = useRouter();
+
+if ($i) {
+ const connection = useStream().useChannel('reversi');
+
+ connection.on('matched', x => {
+ startGame(x.game);
+ });
+
+ connection.on('invited', invitation => {
+ if (invitations.value.some(x => x.id === invitation.user.id)) return;
+ invitations.value.unshift(invitation.user);
+ });
+
+ onUnmounted(() => {
+ connection.dispose();
+ });
+}
+
+const invitations = ref<Misskey.entities.UserLite[]>([]);
+const matchingUser = ref<Misskey.entities.UserLite | null>(null);
+const matchingAny = ref<boolean>(false);
+
+function startGame(game: Misskey.entities.ReversiGameDetailed) {
+ matchingUser.value = null;
+ matchingAny.value = false;
+ router.push(`/reversi/g/${game.id}`);
+}
+
+async function matchHeatbeat() {
+ if (matchingUser.value) {
+ const res = await misskeyApi('reversi/match', {
+ userId: matchingUser.value.id,
+ });
+
+ if (res != null) {
+ startGame(res);
+ }
+ } else if (matchingAny.value) {
+ const res = await misskeyApi('reversi/match', {
+ userId: null,
+ });
+
+ if (res != null) {
+ startGame(res);
+ }
+ }
+}
+
+async function matchUser() {
+ const user = await os.selectUser({ local: true });
+ if (user == null) return;
+
+ matchingUser.value = user;
+
+ matchHeatbeat();
+}
+
+async function matchAny() {
+ matchingAny.value = true;
+
+ matchHeatbeat();
+}
+
+function cancelMatching() {
+ if (matchingUser.value) {
+ misskeyApi('reversi/cancel-match', { userId: matchingUser.value.id });
+ matchingUser.value = null;
+ } else if (matchingAny.value) {
+ misskeyApi('reversi/cancel-match', { userId: null });
+ matchingAny.value = false;
+ }
+}
+
+async function accept(user) {
+ const game = await misskeyApi('reversi/match', {
+ userId: user.id,
+ });
+ if (game) {
+ startGame(game);
+ }
+}
+
+useInterval(matchHeatbeat, 1000 * 10, { immediate: false, afterMounted: true });
+
+onMounted(() => {
+ misskeyApi('reversi/invitations').then(_invitations => {
+ invitations.value = _invitations;
+ });
+});
+
+definePageMetadata(computed(() => ({
+ title: 'Reversi',
+ icon: 'ti ti-device-gamepad',
+})));
+</script>
+
+<style lang="scss" module>
+.invitation {
+ display: flex;
+ box-sizing: border-box;
+ width: 100%;
+ padding: 16px;
+ line-height: 32px;
+ text-align: left;
+}
+
+.gamePreviews {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: var(--margin);
+}
+
+.gamePreview {
+ font-size: 90%;
+ border-radius: 8px;
+ overflow: clip;
+}
+
+.gamePreviewPlayers {
+ text-align: center;
+ padding: 16px;
+ line-height: 32px;
+}
+
+.gamePreviewPlayersAvatar {
+ width: 32px;
+ height: 32px;
+
+ &:first-child {
+ margin-right: 8px;
+ }
+
+ &:last-child {
+ margin-left: 8px;
+ }
+}
+
+.gamePreviewFooter {
+ display: flex;
+ align-items: baseline;
+ border-top: solid 0.5px var(--divider);
+ padding: 6px 10px;
+ font-size: 0.9em;
+}
+
+.waitingScreen {
+ text-align: center;
+}
+
+.waitingScreenTitle {
+ font-size: 1.5em;
+ margin-bottom: 16px;
+ margin-top: 32px;
+}
+</style>
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 98fe0043c1..8cdc7b59c6 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -103,7 +103,7 @@ export function getConfig(): UserConfig {
// https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies
optimizeDeps: {
- include: ['misskey-js'],
+ include: ['misskey-js', 'misskey-reversi'],
},
build: {
@@ -135,7 +135,7 @@ export function getConfig(): UserConfig {
// https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies
commonjsOptions: {
- include: [/misskey-js/, /node_modules/],
+ include: [/misskey-js/, /misskey-reversi/, /node_modules/],
},
},
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index f955cc5cc1..2b95e01533 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1623,6 +1623,16 @@ declare namespace entities {
BubbleGameRegisterResponse,
BubbleGameRankingRequest,
BubbleGameRankingResponse,
+ ReversiCancelMatchRequest,
+ ReversiCancelMatchResponse,
+ ReversiGamesRequest,
+ ReversiGamesResponse,
+ ReversiMatchRequest,
+ ReversiMatchResponse,
+ ReversiInvitationsResponse,
+ ReversiShowGameRequest,
+ ReversiShowGameResponse,
+ ReversiSurrenderRequest,
Error_2 as Error,
UserLite,
UserDetailedNotMeOnly,
@@ -1659,7 +1669,9 @@ declare namespace entities {
Flash,
Signin,
RoleLite,
- Role
+ Role,
+ ReversiGameLite,
+ ReversiGameDetailed
}
}
export { entities }
@@ -2597,6 +2609,42 @@ type ResetPasswordRequest = operations['reset-password']['requestBody']['content
type RetentionResponse = operations['retention']['responses']['200']['content']['application/json'];
// @public (undocumented)
+type ReversiCancelMatchRequest = operations['reversi/cancel-match']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiCancelMatchResponse = operations['reversi/cancel-match']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiGameDetailed = components['schemas']['ReversiGameDetailed'];
+
+// @public (undocumented)
+type ReversiGameLite = components['schemas']['ReversiGameLite'];
+
+// @public (undocumented)
+type ReversiGamesRequest = operations['reversi/games']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiGamesResponse = operations['reversi/games']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiInvitationsResponse = operations['reversi/invitations']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiMatchRequest = operations['reversi/match']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiMatchResponse = operations['reversi/match']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiShowGameRequest = operations['reversi/show-game']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
type Role = components['schemas']['Role'];
// @public (undocumented)
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index b60f449a71..e4e7d13668 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-13T04:31:38.782Z
+ * generatedAt: 2024-01-19T11:00:07.160Z
*/
import type { SwitchCaseResponseType } from '../api.js';
@@ -4007,5 +4007,71 @@ declare module '../api.js' {
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ request<E extends 'reversi/cancel-match', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *No*
+ */
+ request<E extends 'reversi/games', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ request<E extends 'reversi/match', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:account*
+ */
+ request<E extends 'reversi/invitations', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *No*
+ */
+ request<E extends 'reversi/show-game', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ request<E extends 'reversi/surrender', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
}
}
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index dc591a7046..671abd78ce 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-13T04:31:38.778Z
+ * generatedAt: 2024-01-19T11:00:07.158Z
*/
import type {
@@ -544,6 +544,16 @@ import type {
BubbleGameRegisterResponse,
BubbleGameRankingRequest,
BubbleGameRankingResponse,
+ ReversiCancelMatchRequest,
+ ReversiCancelMatchResponse,
+ ReversiGamesRequest,
+ ReversiGamesResponse,
+ ReversiMatchRequest,
+ ReversiMatchResponse,
+ ReversiInvitationsResponse,
+ ReversiShowGameRequest,
+ ReversiShowGameResponse,
+ ReversiSurrenderRequest,
} from './entities.js';
export type Endpoints = {
@@ -907,4 +917,10 @@ export type Endpoints = {
'retention': { req: EmptyRequest; res: RetentionResponse };
'bubble-game/register': { req: BubbleGameRegisterRequest; res: BubbleGameRegisterResponse };
'bubble-game/ranking': { req: BubbleGameRankingRequest; res: BubbleGameRankingResponse };
+ 'reversi/cancel-match': { req: ReversiCancelMatchRequest; res: ReversiCancelMatchResponse };
+ 'reversi/games': { req: ReversiGamesRequest; res: ReversiGamesResponse };
+ 'reversi/match': { req: ReversiMatchRequest; res: ReversiMatchResponse };
+ 'reversi/invitations': { req: EmptyRequest; res: ReversiInvitationsResponse };
+ 'reversi/show-game': { req: ReversiShowGameRequest; res: ReversiShowGameResponse };
+ 'reversi/surrender': { req: ReversiSurrenderRequest; res: EmptyResponse };
}
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index dfe24ce0d8..c14876c0e3 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-13T04:31:38.775Z
+ * generatedAt: 2024-01-19T11:00:07.156Z
*/
import { operations } from './types.js';
@@ -546,3 +546,13 @@ export type BubbleGameRegisterRequest = operations['bubble-game/register']['requ
export type BubbleGameRegisterResponse = operations['bubble-game/register']['responses']['200']['content']['application/json'];
export type BubbleGameRankingRequest = operations['bubble-game/ranking']['requestBody']['content']['application/json'];
export type BubbleGameRankingResponse = operations['bubble-game/ranking']['responses']['200']['content']['application/json'];
+export type ReversiCancelMatchRequest = operations['reversi/cancel-match']['requestBody']['content']['application/json'];
+export type ReversiCancelMatchResponse = operations['reversi/cancel-match']['responses']['200']['content']['application/json'];
+export type ReversiGamesRequest = operations['reversi/games']['requestBody']['content']['application/json'];
+export type ReversiGamesResponse = operations['reversi/games']['responses']['200']['content']['application/json'];
+export type ReversiMatchRequest = operations['reversi/match']['requestBody']['content']['application/json'];
+export type ReversiMatchResponse = operations['reversi/match']['responses']['200']['content']['application/json'];
+export type ReversiInvitationsResponse = operations['reversi/invitations']['responses']['200']['content']['application/json'];
+export type ReversiShowGameRequest = operations['reversi/show-game']['requestBody']['content']['application/json'];
+export type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200']['content']['application/json'];
+export type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index 5c6bebf2fd..78f14d2250 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-13T04:31:38.773Z
+ * generatedAt: 2024-01-19T11:00:07.155Z
*/
import { components } from './types.js';
@@ -41,3 +41,5 @@ export type Flash = components['schemas']['Flash'];
export type Signin = components['schemas']['Signin'];
export type RoleLite = components['schemas']['RoleLite'];
export type Role = components['schemas']['Role'];
+export type ReversiGameLite = components['schemas']['ReversiGameLite'];
+export type ReversiGameDetailed = components['schemas']['ReversiGameDetailed'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 76e2b5309c..36facf6e28 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-13T04:31:38.633Z
+ * generatedAt: 2024-01-19T11:00:07.077Z
*/
/**
@@ -3472,6 +3472,60 @@ export type paths = {
*/
post: operations['bubble-game/ranking'];
};
+ '/reversi/cancel-match': {
+ /**
+ * reversi/cancel-match
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ post: operations['reversi/cancel-match'];
+ };
+ '/reversi/games': {
+ /**
+ * reversi/games
+ * @description No description provided.
+ *
+ * **Credential required**: *No*
+ */
+ post: operations['reversi/games'];
+ };
+ '/reversi/match': {
+ /**
+ * reversi/match
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ post: operations['reversi/match'];
+ };
+ '/reversi/invitations': {
+ /**
+ * reversi/invitations
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:account*
+ */
+ post: operations['reversi/invitations'];
+ };
+ '/reversi/show-game': {
+ /**
+ * reversi/show-game
+ * @description No description provided.
+ *
+ * **Credential required**: *No*
+ */
+ post: operations['reversi/show-game'];
+ };
+ '/reversi/surrender': {
+ /**
+ * reversi/surrender
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ post: operations['reversi/surrender'];
+ };
};
export type webhooks = Record<string, never>;
@@ -4404,6 +4458,72 @@ export type components = {
};
usersCount: number;
});
+ ReversiGameLite: {
+ /** Format: id */
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** Format: date-time */
+ startedAt: string | null;
+ isStarted: boolean;
+ isEnded: boolean;
+ form1: Record<string, never> | null;
+ form2: Record<string, never> | null;
+ user1Ready: boolean;
+ user2Ready: boolean;
+ /** Format: id */
+ user1Id: string;
+ /** Format: id */
+ user2Id: string;
+ user1: components['schemas']['User'];
+ user2: components['schemas']['User'];
+ /** Format: id */
+ winnerId: string | null;
+ winner: components['schemas']['User'] | null;
+ /** Format: id */
+ surrendered: string | null;
+ black: number | null;
+ bw: string;
+ isLlotheo: boolean;
+ canPutEverywhere: boolean;
+ loopedBoard: boolean;
+ };
+ ReversiGameDetailed: {
+ /** Format: id */
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** Format: date-time */
+ startedAt: string | null;
+ isStarted: boolean;
+ isEnded: boolean;
+ form1: Record<string, never> | null;
+ form2: Record<string, never> | null;
+ user1Ready: boolean;
+ user2Ready: boolean;
+ /** Format: id */
+ user1Id: string;
+ /** Format: id */
+ user2Id: string;
+ user1: components['schemas']['User'];
+ user2: components['schemas']['User'];
+ /** Format: id */
+ winnerId: string | null;
+ winner: components['schemas']['User'] | null;
+ /** Format: id */
+ surrendered: string | null;
+ black: number | null;
+ bw: string;
+ isLlotheo: boolean;
+ canPutEverywhere: boolean;
+ loopedBoard: boolean;
+ logs: {
+ at: number;
+ color: boolean;
+ pos: number;
+ }[];
+ map: string[];
+ };
};
responses: never;
parameters: never;
@@ -25542,5 +25662,325 @@ export type operations = {
};
};
};
+ /**
+ * reversi/cancel-match
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ 'reversi/cancel-match': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ userId?: string | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * reversi/games
+ * @description No description provided.
+ *
+ * **Credential required**: *No*
+ */
+ 'reversi/games': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 10 */
+ limit?: number;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ /** @default false */
+ my?: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ReversiGameLite'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * reversi/match
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ 'reversi/match': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ userId?: string | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * reversi/invitations
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:account*
+ */
+ 'reversi/invitations': {
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['UserLite'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * reversi/show-game
+ * @description No description provided.
+ *
+ * **Credential required**: *No*
+ */
+ 'reversi/show-game': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ gameId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ReversiGameDetailed'];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * reversi/surrender
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ 'reversi/surrender': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ gameId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (without any results) */
+ 204: {
+ content: never;
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
};
diff --git a/packages/misskey-reversi/package.json b/packages/misskey-reversi/package.json
new file mode 100644
index 0000000000..8d3ca30166
--- /dev/null
+++ b/packages/misskey-reversi/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "misskey-reversi",
+ "version": "0.0.1",
+ "main": "./built/index.js",
+ "types": "./built/index.d.ts",
+ "scripts": {
+ "build": "tsc",
+ "watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build\"",
+ "eslint": "eslint . --ext .js,.jsx,.ts,.tsx",
+ "typecheck": "tsc --noEmit",
+ "lint": "pnpm typecheck && pnpm eslint"
+ },
+ "devDependencies": {
+ "@misskey-dev/eslint-plugin": "1.0.0",
+ "@types/node": "20.11.5",
+ "@typescript-eslint/eslint-plugin": "6.19.0",
+ "@typescript-eslint/parser": "6.19.0",
+ "eslint": "8.56.0",
+ "typescript": "5.3.3"
+ },
+ "files": [
+ "built"
+ ],
+ "dependencies": {
+ }
+}
diff --git a/packages/misskey-reversi/src/game.ts b/packages/misskey-reversi/src/game.ts
new file mode 100644
index 0000000000..55d0b84da7
--- /dev/null
+++ b/packages/misskey-reversi/src/game.ts
@@ -0,0 +1,216 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * true ... 黒
+ * false ... 白
+ */
+export type Color = boolean;
+const BLACK = true;
+const WHITE = false;
+
+export type MapCell = 'null' | 'empty';
+
+export type Options = {
+ isLlotheo: boolean;
+ canPutEverywhere: boolean;
+ loopedBoard: boolean;
+};
+
+export type Undo = {
+ color: Color;
+ pos: number;
+
+ /**
+ * 反転した石の位置の配列
+ */
+ effects: number[];
+
+ turn: Color | null;
+};
+
+export class Game {
+ public map: MapCell[];
+ public mapWidth: number;
+ public mapHeight: number;
+ public board: (Color | null | undefined)[];
+ public turn: Color | null = BLACK;
+ public opts: Options;
+
+ public prevPos = -1;
+ public prevColor: Color | null = null;
+
+ private logs: Undo[] = [];
+
+ constructor(map: string[], opts: Options) {
+ //#region binds
+ this.put = this.put.bind(this);
+ //#endregion
+
+ //#region Options
+ this.opts = opts;
+ if (this.opts.isLlotheo == null) this.opts.isLlotheo = false;
+ if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false;
+ if (this.opts.loopedBoard == null) this.opts.loopedBoard = false;
+ //#endregion
+
+ //#region Parse map data
+ this.mapWidth = map[0].length;
+ this.mapHeight = map.length;
+ const mapData = map.join('');
+
+ this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined);
+
+ this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null');
+ //#endregion
+
+ // ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
+ if (!this.canPutSomewhere(BLACK))
+ this.turn = this.canPutSomewhere(WHITE) ? WHITE : null;
+ }
+
+ public get blackCount() {
+ return this.board.filter(x => x === BLACK).length;
+ }
+
+ public get whiteCount() {
+ return this.board.filter(x => x === WHITE).length;
+ }
+
+ public posToXy(pos: number): number[] {
+ const x = pos % this.mapWidth;
+ const y = Math.floor(pos / this.mapWidth);
+ return [x, y];
+ }
+
+ public xyToPos(x: number, y: number): number {
+ return x + (y * this.mapWidth);
+ }
+
+ public put(color: Color, pos: number) {
+ this.prevPos = pos;
+ this.prevColor = color;
+
+ this.board[pos] = color;
+
+ // 反転させられる石を取得
+ const effects = this.effects(color, pos);
+
+ // 反転させる
+ for (const pos of effects) {
+ this.board[pos] = color;
+ }
+
+ const turn = this.turn;
+
+ this.logs.push({
+ color,
+ pos,
+ effects,
+ turn
+ });
+
+ this.calcTurn();
+ }
+
+ private calcTurn() {
+ // ターン計算
+ this.turn =
+ this.canPutSomewhere(!this.prevColor) ? !this.prevColor :
+ this.canPutSomewhere(this.prevColor!) ? this.prevColor :
+ null;
+ }
+
+ public undo() {
+ const undo = this.logs.pop()!;
+ this.prevColor = undo.color;
+ this.prevPos = undo.pos;
+ this.board[undo.pos] = null;
+ for (const pos of undo.effects) {
+ const color = this.board[pos];
+ this.board[pos] = !color;
+ }
+ this.turn = undo.turn;
+ }
+
+ public mapDataGet(pos: number): MapCell {
+ const [x, y] = this.posToXy(pos);
+ return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos];
+ }
+
+ public getPuttablePlaces(color: Color): number[] {
+ return Array.from(this.board.keys()).filter(i => this.canPut(color, i));
+ }
+
+ public canPutSomewhere(color: Color): boolean {
+ return this.getPuttablePlaces(color).length > 0;
+ }
+
+ public canPut(color: Color, pos: number): boolean {
+ return (
+ this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない
+ this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード
+ this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか
+ }
+
+ /**
+ * 指定のマスに石を置いた時の、反転させられる石を取得します
+ * @param color 自分の色
+ * @param initPos 位置
+ */
+ public effects(color: Color, initPos: number): number[] {
+ const enemyColor = !color;
+
+ const diffVectors: [number, number][] = [
+ [ 0, -1], // 上
+ [+1, -1], // 右上
+ [+1, 0], // 右
+ [+1, +1], // 右下
+ [ 0, +1], // 下
+ [-1, +1], // 左下
+ [-1, 0], // 左
+ [-1, -1] // 左上
+ ];
+
+ const effectsInLine = ([dx, dy]: [number, number]): number[] => {
+ const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy];
+
+ const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
+ let [x, y] = this.posToXy(initPos);
+ while (true) {
+ [x, y] = nextPos(x, y);
+
+ // 座標が指し示す位置がボード外に出たとき
+ if (this.opts.loopedBoard && this.xyToPos(
+ (x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth),
+ (y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) === initPos)
+ // 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
+ return found;
+ else if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight)
+ return []; // 挟めないことが確定 (盤面外に到達)
+
+ const pos = this.xyToPos(x, y);
+ if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
+ const stone = this.board[pos];
+ if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
+ if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
+ if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
+ }
+ };
+
+ return ([] as number[]).concat(...diffVectors.map(effectsInLine));
+ }
+
+ public get isEnded(): boolean {
+ return this.turn === null;
+ }
+
+ public get winner(): Color | null {
+ return this.isEnded ?
+ this.blackCount == this.whiteCount ? null :
+ this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK :
+ undefined as never;
+ }
+}
diff --git a/packages/misskey-reversi/src/index.ts b/packages/misskey-reversi/src/index.ts
new file mode 100644
index 0000000000..20ed36f208
--- /dev/null
+++ b/packages/misskey-reversi/src/index.ts
@@ -0,0 +1,7 @@
+import { Game } from './game.js';
+
+export {
+ Game,
+};
+
+export * as maps from './maps.js';
diff --git a/packages/misskey-reversi/src/maps.ts b/packages/misskey-reversi/src/maps.ts
new file mode 100644
index 0000000000..85cf1a0485
--- /dev/null
+++ b/packages/misskey-reversi/src/maps.ts
@@ -0,0 +1,715 @@
+/**
+ * 組み込みマップ定義
+ *
+ * データ値:
+ * (スペース) ... マス無し
+ * - ... マス
+ * b ... 初期配置される黒石
+ * w ... 初期配置される白石
+ */
+
+export type Map = {
+ name?: string;
+ category?: string;
+ author?: string;
+ data: string[];
+};
+
+export const fourfour: Map = {
+ name: '4x4',
+ category: '4x4',
+ data: [
+ '----',
+ '-wb-',
+ '-bw-',
+ '----'
+ ]
+};
+
+export const sixsix: Map = {
+ name: '6x6',
+ category: '6x6',
+ data: [
+ '------',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ '------'
+ ]
+};
+
+export const roundedSixsix: Map = {
+ name: '6x6 rounded',
+ category: '6x6',
+ author: 'syuilo',
+ data: [
+ ' ---- ',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ ' ---- '
+ ]
+};
+
+export const roundedSixsix2: Map = {
+ name: '6x6 rounded 2',
+ category: '6x6',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ---- ',
+ '--wb--',
+ '--bw--',
+ ' ---- ',
+ ' -- '
+ ]
+};
+
+export const eighteight: Map = {
+ name: '8x8',
+ category: '8x8',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const eighteightH28: Map = {
+ name: '8x8 handicap 28',
+ category: '8x8',
+ data: [
+ 'bbbbbbbb',
+ 'b------b',
+ 'b------b',
+ 'b--wb--b',
+ 'b--bw--b',
+ 'b------b',
+ 'b------b',
+ 'bbbbbbbb'
+ ]
+};
+
+export const roundedEighteight: Map = {
+ name: '8x8 rounded',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const roundedEighteight2: Map = {
+ name: '8x8 rounded 2',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ---- ',
+ ' ------ ',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ ' ------ ',
+ ' ---- '
+ ]
+};
+
+export const roundedEighteight3: Map = {
+ name: '8x8 rounded 3',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ---- ',
+ ' ------ ',
+ '---wb---',
+ '---bw---',
+ ' ------ ',
+ ' ---- ',
+ ' -- '
+ ]
+};
+
+export const eighteightWithNotch: Map = {
+ name: '8x8 with notch',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '--------',
+ '--------',
+ ' --wb-- ',
+ ' --bw-- ',
+ '--------',
+ '--------',
+ '--- ---'
+ ]
+};
+
+export const eighteightWithSomeHoles: Map = {
+ name: '8x8 with some holes',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--- ----',
+ '----- --',
+ '-- -----',
+ '---wb---',
+ '---bw- -',
+ ' -------',
+ '--- ----',
+ '--------'
+ ]
+};
+
+export const circle: Map = {
+ name: 'Circle',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ------ ',
+ ' ------ ',
+ '---wb---',
+ '---bw---',
+ ' ------ ',
+ ' ------ ',
+ ' -- '
+ ]
+};
+
+export const smile: Map = {
+ name: 'Smile',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ '--------',
+ '-- -- --',
+ '---wb---',
+ '-- bw --',
+ '--- ---',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const window: Map = {
+ name: 'Window',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--------',
+ '- -- -',
+ '- -- -',
+ '---wb---',
+ '---bw---',
+ '- -- -',
+ '- -- -',
+ '--------'
+ ]
+};
+
+export const reserved: Map = {
+ name: 'Reserved',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ 'w------b',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ 'b------w'
+ ]
+};
+
+export const x: Map = {
+ name: 'X',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ 'w------b',
+ '-w----b-',
+ '--w--b--',
+ '---wb---',
+ '---bw---',
+ '--b--w--',
+ '-b----w-',
+ 'b------w'
+ ]
+};
+
+export const parallel: Map = {
+ name: 'Parallel',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---bb---',
+ '---ww---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const lackOfBlack: Map = {
+ name: 'Lack of Black',
+ category: '8x8',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---w----',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const squareParty: Map = {
+ name: 'Square Party',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--------',
+ '-wwwbbb-',
+ '-w-wb-b-',
+ '-wwwbbb-',
+ '-bbbwww-',
+ '-b-bw-w-',
+ '-bbbwww-',
+ '--------'
+ ]
+};
+
+export const minesweeper: Map = {
+ name: 'Minesweeper',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ 'b-b--w-w',
+ '-w-wb-b-',
+ 'w-b--w-b',
+ '-b-wb-w-',
+ '-w-bw-b-',
+ 'b-w--b-w',
+ '-b-bw-w-',
+ 'w-w--b-b'
+ ]
+};
+
+export const tenthtenth: Map = {
+ name: '10x10',
+ category: '10x10',
+ data: [
+ '----------',
+ '----------',
+ '----------',
+ '----------',
+ '----wb----',
+ '----bw----',
+ '----------',
+ '----------',
+ '----------',
+ '----------'
+ ]
+};
+
+export const hole: Map = {
+ name: 'The Hole',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '----------',
+ '----------',
+ '--wb--wb--',
+ '--bw--bw--',
+ '---- ----',
+ '---- ----',
+ '--wb--wb--',
+ '--bw--bw--',
+ '----------',
+ '----------'
+ ]
+};
+
+export const grid: Map = {
+ name: 'Grid',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '----------',
+ '- - -- - -',
+ '----------',
+ '- - -- - -',
+ '----wb----',
+ '----bw----',
+ '- - -- - -',
+ '----------',
+ '- - -- - -',
+ '----------'
+ ]
+};
+
+export const cross: Map = {
+ name: 'Cross',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ ' ---- ',
+ ' ---- ',
+ ' ---- ',
+ '----------',
+ '----wb----',
+ '----bw----',
+ '----------',
+ ' ---- ',
+ ' ---- ',
+ ' ---- '
+ ]
+};
+
+export const charX: Map = {
+ name: 'Char X',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '---- ----',
+ '----------',
+ ' -------- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' -------- ',
+ '----------',
+ '---- ----',
+ '--- ---'
+ ]
+};
+
+export const charY: Map = {
+ name: 'Char Y',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '---- ----',
+ '----------',
+ ' -------- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' ------ ',
+ ' ------ ',
+ ' ------ ',
+ ' ------ '
+ ]
+};
+
+export const walls: Map = {
+ name: 'Walls',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ ' bbbbbbbb ',
+ 'w--------w',
+ 'w--------w',
+ 'w--------w',
+ 'w---wb---w',
+ 'w---bw---w',
+ 'w--------w',
+ 'w--------w',
+ 'w--------w',
+ ' bbbbbbbb '
+ ]
+};
+
+export const cpu: Map = {
+ name: 'CPU',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ ' b b b b ',
+ 'w--------w',
+ ' -------- ',
+ 'w--------w',
+ ' ---wb--- ',
+ ' ---bw--- ',
+ 'w--------w',
+ ' -------- ',
+ 'w--------w',
+ ' b b b b '
+ ]
+};
+
+export const checker: Map = {
+ name: 'Checker',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ '----------',
+ '----------',
+ '----------',
+ '---wbwb---',
+ '---bwbw---',
+ '---wbwb---',
+ '---bwbw---',
+ '----------',
+ '----------',
+ '----------'
+ ]
+};
+
+export const japaneseCurry: Map = {
+ name: 'Japanese curry',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ 'w-b-b-b-b-',
+ '-w-b-b-b-b',
+ 'w-w-b-b-b-',
+ '-w-w-b-b-b',
+ 'w-w-wwb-b-',
+ '-w-wbb-b-b',
+ 'w-w-w-b-b-',
+ '-w-w-w-b-b',
+ 'w-w-w-w-b-',
+ '-w-w-w-w-b'
+ ]
+};
+
+export const mosaic: Map = {
+ name: 'Mosaic',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '- - - - - ',
+ ' - - - - -',
+ '- - - - - ',
+ ' - w w - -',
+ '- - b b - ',
+ ' - w w - -',
+ '- - b b - ',
+ ' - - - - -',
+ '- - - - - ',
+ ' - - - - -',
+ ]
+};
+
+export const arena: Map = {
+ name: 'Arena',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '- - -- - -',
+ ' - - - - ',
+ '- ------ -',
+ ' -------- ',
+ '- --wb-- -',
+ '- --bw-- -',
+ ' -------- ',
+ '- ------ -',
+ ' - - - - ',
+ '- - -- - -'
+ ]
+};
+
+export const reactor: Map = {
+ name: 'Reactor',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '-w------b-',
+ 'b- - - -w',
+ '- --wb-- -',
+ '---b w---',
+ '- b wb w -',
+ '- w bw b -',
+ '---w b---',
+ '- --bw-- -',
+ 'w- - - -b',
+ '-b------w-'
+ ]
+};
+
+export const sixeight: Map = {
+ name: '6x8',
+ category: 'Special',
+ data: [
+ '------',
+ '------',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ '------',
+ '------'
+ ]
+};
+
+export const spark: Map = {
+ name: 'Spark',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' - - ',
+ '----------',
+ ' -------- ',
+ ' -------- ',
+ ' ---wb--- ',
+ ' ---bw--- ',
+ ' -------- ',
+ ' -------- ',
+ '----------',
+ ' - - '
+ ]
+};
+
+export const islands: Map = {
+ name: 'Islands',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ '-------- ',
+ '---wb--- ',
+ '---bw--- ',
+ '-------- ',
+ ' - - ',
+ ' - - ',
+ ' --------',
+ ' --------',
+ ' --------',
+ ' --------'
+ ]
+};
+
+export const galaxy: Map = {
+ name: 'Galaxy',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ ' --www--- ',
+ ' ------w--- ',
+ '---bbb--w---',
+ '--b---b-w-b-',
+ '-b--wwb-w-b-',
+ '-b-w-bww--b-',
+ '-b-w-b---b--',
+ '---w--bbb---',
+ ' ---w------ ',
+ ' ---www-- ',
+ ' ------ '
+ ]
+};
+
+export const triangle: Map = {
+ name: 'Triangle',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' -- ',
+ ' ---- ',
+ ' ---- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' -------- ',
+ ' -------- ',
+ '----------',
+ '----------'
+ ]
+};
+
+export const iphonex: Map = {
+ name: 'iPhone X',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' -- -- ',
+ '--------',
+ '--------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const dealWithIt: Map = {
+ name: 'Deal with it!',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ '------------',
+ '--w-b-------',
+ ' --b-w------',
+ ' --w-b---- ',
+ ' ------- '
+ ]
+};
+
+export const bigBoard: Map = {
+ name: 'Big board',
+ category: 'Special',
+ data: [
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '-------wb-------',
+ '-------bw-------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------'
+ ]
+};
+
+export const twoBoard: Map = {
+ name: 'Two board',
+ category: 'Special',
+ author: 'Aya',
+ data: [
+ '-------- --------',
+ '-------- --------',
+ '-------- --------',
+ '---wb--- ---wb---',
+ '---bw--- ---bw---',
+ '-------- --------',
+ '-------- --------',
+ '-------- --------'
+ ]
+};
diff --git a/packages/misskey-reversi/tsconfig.json b/packages/misskey-reversi/tsconfig.json
new file mode 100644
index 0000000000..f56b65e868
--- /dev/null
+++ b/packages/misskey-reversi/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./built/",
+ "removeComments": true,
+ "strict": true,
+ "strictFunctionTypes": true,
+ "strictNullChecks": true,
+ "experimentalDecorators": true,
+ "noImplicitReturns": true,
+ "esModuleInterop": true,
+ "typeRoots": [
+ "./node_modules/@types"
+ ],
+ "lib": [
+ "esnext",
+ "dom"
+ ]
+ },
+ "include": [
+ "src/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "test/**/*"
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 825a7ab860..31394eb081 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -185,6 +185,9 @@ importers:
content-disposition:
specifier: 0.5.4
version: 0.5.4
+ crc-32:
+ specifier: ^1.2.2
+ version: 1.2.2
date-fns:
specifier: 2.30.0
version: 2.30.0
@@ -263,6 +266,9 @@ importers:
misskey-js:
specifier: workspace:*
version: link:../misskey-js
+ misskey-reversi:
+ specifier: workspace:*
+ version: link:../misskey-reversi
ms:
specifier: 3.0.0-canary.1
version: 3.0.0-canary.1
@@ -736,6 +742,9 @@ importers:
compare-versions:
specifier: 6.1.0
version: 6.1.0
+ crc-32:
+ specifier: ^1.2.2
+ version: 1.2.2
cropperjs:
specifier: 2.0.0-beta.4
version: 2.0.0-beta.4
@@ -772,6 +781,9 @@ importers:
misskey-js:
specifier: workspace:*
version: link:../misskey-js
+ misskey-reversi:
+ specifier: workspace:*
+ version: link:../misskey-reversi
photoswipe:
specifier: 5.4.3
version: 5.4.3
@@ -1114,6 +1126,27 @@ importers:
specifier: 5.3.3
version: 5.3.3
+ packages/misskey-reversi:
+ devDependencies:
+ '@misskey-dev/eslint-plugin':
+ specifier: 1.0.0
+ version: 1.0.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.19.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0)
+ '@types/node':
+ specifier: 20.11.5
+ version: 20.11.5
+ '@typescript-eslint/eslint-plugin':
+ specifier: 6.19.0
+ version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: 6.19.0
+ version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ eslint:
+ specifier: 8.56.0
+ version: 8.56.0
+ typescript:
+ specifier: 5.3.3
+ version: 5.3.3
+
packages/sw:
dependencies:
esbuild:
@@ -1128,7 +1161,7 @@ importers:
devDependencies:
'@misskey-dev/eslint-plugin':
specifier: ^1.0.0
- version: 1.0.0(@typescript-eslint/eslint-plugin@6.14.0)(@typescript-eslint/parser@6.14.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0)
+ version: 1.0.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.14.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0)
'@typescript-eslint/parser':
specifier: 6.14.0
version: 6.14.0(eslint@8.56.0)(typescript@5.3.3)
@@ -1812,7 +1845,7 @@ packages:
'@babel/traverse': 7.22.11
'@babel/types': 7.22.17
convert-source-map: 1.9.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -1835,7 +1868,7 @@ packages:
'@babel/traverse': 7.23.5
'@babel/types': 7.23.5
convert-source-map: 2.0.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -1937,7 +1970,7 @@ packages:
'@babel/core': 7.23.5
'@babel/helper-compilation-targets': 7.22.15
'@babel/helper-plugin-utils': 7.22.5
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
lodash.debounce: 4.0.8
resolve: 1.22.8
transitivePeerDependencies:
@@ -3336,7 +3369,7 @@ packages:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.23.5
'@babel/types': 7.22.17
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -3354,7 +3387,7 @@ packages:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.23.5
'@babel/types': 7.23.5
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -4233,7 +4266,7 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
ajv: 6.12.6
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
espree: 9.6.1
globals: 13.19.0
ignore: 5.2.4
@@ -4250,7 +4283,7 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
ajv: 6.12.6
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
espree: 9.6.1
globals: 13.19.0
ignore: 5.2.4
@@ -4515,7 +4548,7 @@ packages:
engines: {node: '>=10.10.0'}
dependencies:
'@humanwhocodes/object-schema': 2.0.1
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -4571,7 +4604,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
chalk: 4.1.2
jest-message-util: 29.7.0
jest-util: 29.7.0
@@ -4592,14 +4625,14 @@ packages:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.7.1
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
- jest-config: 29.7.0(@types/node@20.10.5)
+ jest-config: 29.7.0(@types/node@20.11.5)
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -4634,7 +4667,7 @@ packages:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
jest-mock: 29.7.0
dev: true
@@ -4661,7 +4694,7 @@ packages:
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -4694,7 +4727,7 @@ packages:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.18
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
chalk: 4.1.2
collect-v8-coverage: 1.0.1
exit: 0.1.2
@@ -4788,7 +4821,7 @@ packages:
dependencies:
'@types/istanbul-lib-coverage': 2.0.4
'@types/istanbul-reports': 3.0.1
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
'@types/yargs': 16.0.5
chalk: 4.1.2
dev: true
@@ -4800,7 +4833,7 @@ packages:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.4
'@types/istanbul-reports': 3.0.1
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
'@types/yargs': 17.0.19
chalk: 4.1.2
dev: true
@@ -4992,6 +5025,34 @@ packages:
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.14.0)(eslint@8.56.0)
dev: true
+ /@misskey-dev/eslint-plugin@1.0.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.14.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0):
+ resolution: {integrity: sha512-dh6UbcrNDVg5DD8k8Qh4ab30OPpuEYIlJCqaBV/lkIV8wNN/AfCJ2V7iTP8V8KjryM4t+sf5IqzQLQnT0mWI4A==}
+ peerDependencies:
+ '@typescript-eslint/eslint-plugin': '>= 6'
+ '@typescript-eslint/parser': '>= 6'
+ eslint: '>= 3'
+ eslint-plugin-import: '>= 2'
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 6.19.0(@typescript-eslint/parser@6.14.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
+ eslint: 8.56.0
+ eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.14.0)(eslint@8.56.0)
+ dev: true
+
+ /@misskey-dev/eslint-plugin@1.0.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.19.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0):
+ resolution: {integrity: sha512-dh6UbcrNDVg5DD8k8Qh4ab30OPpuEYIlJCqaBV/lkIV8wNN/AfCJ2V7iTP8V8KjryM4t+sf5IqzQLQnT0mWI4A==}
+ peerDependencies:
+ '@typescript-eslint/eslint-plugin': '>= 6'
+ '@typescript-eslint/parser': '>= 6'
+ eslint: '>= 3'
+ eslint-plugin-import: '>= 2'
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ eslint: 8.56.0
+ eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)
+ dev: true
+
/@misskey-dev/sharp-read-bmp@1.1.1:
resolution: {integrity: sha512-X52BQYL/I9mafypQ+wBhst+BUlYiPWnHhKGcF6ybcYSLl+zhcV0q5mezIXHozhM0Sv0A7xCdrWmR7TCNxHLrtQ==}
dependencies:
@@ -5089,7 +5150,7 @@ packages:
'@open-draft/until': 1.0.3
'@types/debug': 4.1.7
'@xmldom/xmldom': 0.8.6
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
headers-polyfill: 3.2.5
outvariant: 1.4.0
strict-event-emitter: 0.2.8
@@ -7992,7 +8053,7 @@ packages:
dependencies:
'@types/http-cache-semantics': 4.0.1
'@types/keyv': 3.1.4
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
'@types/responselike': 1.0.0
dev: false
@@ -8025,7 +8086,7 @@ packages:
/@types/connect@3.4.35:
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
/@types/content-disposition@0.5.8:
@@ -8039,7 +8100,7 @@ packages:
/@types/cross-spawn@6.0.2:
resolution: {integrity: sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
/@types/debug@4.1.7:
@@ -8097,7 +8158,7 @@ packages:
/@types/express-serve-static-core@4.17.33:
resolution: {integrity: sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
'@types/qs': 6.9.7
'@types/range-parser': 1.2.4
dev: true
@@ -8125,13 +8186,13 @@ packages:
resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==}
dependencies:
'@types/minimatch': 5.1.2
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
/@types/graceful-fs@4.1.6:
resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
/@types/http-cache-semantics@4.0.1:
@@ -8212,7 +8273,7 @@ packages:
/@types/keyv@3.1.4:
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: false
/@types/lodash@4.14.191:
@@ -8261,7 +8322,7 @@ packages:
/@types/node-fetch@2.6.4:
resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
form-data: 3.0.1
/@types/node-fetch@3.0.3:
@@ -8279,6 +8340,11 @@ packages:
dependencies:
undici-types: 5.26.5
+ /@types/node@20.11.5:
+ resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==}
+ dependencies:
+ undici-types: 5.26.5
+
/@types/node@20.9.1:
resolution: {integrity: sha512-HhmzZh5LSJNS5O8jQKpJ/3ZcrrlG6L70hpGqMIAoM9YVD0YBRNWYsfwcXq8VnSjlNpCpgLzMXdiPo+dxcvSmiA==}
dependencies:
@@ -8381,7 +8447,7 @@ packages:
/@types/readdir-glob@1.1.1:
resolution: {integrity: sha512-ImM6TmoF8bgOwvehGviEj3tRdRBbQujr1N+0ypaln/GWjaerOB26jb93vsRHmdMtvVQZQebOlqt2HROark87mQ==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
/@types/rename@1.0.7:
@@ -8395,7 +8461,7 @@ packages:
/@types/responselike@1.0.0:
resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: false
/@types/sanitize-html@2.9.5:
@@ -8421,7 +8487,7 @@ packages:
resolution: {integrity: sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==}
dependencies:
'@types/mime': 3.0.1
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
/@types/serviceworker@0.0.67:
@@ -8431,7 +8497,7 @@ packages:
/@types/set-cookie-parser@2.4.3:
resolution: {integrity: sha512-7QhnH7bi+6KAhBB+Auejz1uV9DHiopZqu7LfR/5gZZTkejJV5nYeZZpgfFoE0N8aDsXuiYpfKyfyMatCwQhyTQ==}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
/@types/sharp@0.32.0:
@@ -8534,7 +8600,7 @@ packages:
resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==}
requiresBuild: true
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
optional: true
@@ -8555,7 +8621,7 @@ packages:
'@typescript-eslint/type-utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3)
'@typescript-eslint/utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.11.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
eslint: 8.53.0
graphemer: 1.4.0
ignore: 5.2.4
@@ -8584,7 +8650,65 @@ packages:
'@typescript-eslint/type-utils': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
'@typescript-eslint/utils': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.14.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
+ eslint: 8.56.0
+ graphemer: 1.4.0
+ ignore: 5.2.4
+ natural-compare: 1.4.0
+ semver: 7.5.4
+ ts-api-utils: 1.0.1(typescript@5.3.3)
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@typescript-eslint/eslint-plugin@6.19.0(@typescript-eslint/parser@6.14.0)(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
+ eslint: ^7.0.0 || ^8.0.0
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ '@eslint-community/regexpp': 4.6.2
+ '@typescript-eslint/parser': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/scope-manager': 6.19.0
+ '@typescript-eslint/type-utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/visitor-keys': 6.19.0
+ debug: 4.3.4(supports-color@5.5.0)
+ eslint: 8.56.0
+ graphemer: 1.4.0
+ ignore: 5.2.4
+ natural-compare: 1.4.0
+ semver: 7.5.4
+ ts-api-utils: 1.0.1(typescript@5.3.3)
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@typescript-eslint/eslint-plugin@6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
+ eslint: ^7.0.0 || ^8.0.0
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ '@eslint-community/regexpp': 4.6.2
+ '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/scope-manager': 6.19.0
+ '@typescript-eslint/type-utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/visitor-keys': 6.19.0
+ debug: 4.3.4(supports-color@5.5.0)
eslint: 8.56.0
graphemer: 1.4.0
ignore: 5.2.4
@@ -8610,7 +8734,7 @@ packages:
'@typescript-eslint/types': 6.11.0
'@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.11.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
eslint: 8.53.0
typescript: 5.3.3
transitivePeerDependencies:
@@ -8631,7 +8755,28 @@ packages:
'@typescript-eslint/types': 6.14.0
'@typescript-eslint/typescript-estree': 6.14.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.14.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
+ eslint: 8.56.0
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-1DyBLG5SH7PYCd00QlroiW60YJ4rWMuUGa/JBV0iZuqi4l4IK3twKPq5ZkEebmGqRjXWVgsUzfd3+nZveewgow==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ peerDependencies:
+ eslint: ^7.0.0 || ^8.0.0
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ '@typescript-eslint/scope-manager': 6.19.0
+ '@typescript-eslint/types': 6.19.0
+ '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+ '@typescript-eslint/visitor-keys': 6.19.0
+ debug: 4.3.4(supports-color@5.5.0)
eslint: 8.56.0
typescript: 5.3.3
transitivePeerDependencies:
@@ -8654,6 +8799,14 @@ packages:
'@typescript-eslint/visitor-keys': 6.14.0
dev: true
+ /@typescript-eslint/scope-manager@6.19.0:
+ resolution: {integrity: sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ dependencies:
+ '@typescript-eslint/types': 6.19.0
+ '@typescript-eslint/visitor-keys': 6.19.0
+ dev: true
+
/@typescript-eslint/type-utils@6.11.0(eslint@8.53.0)(typescript@5.3.3):
resolution: {integrity: sha512-nA4IOXwZtqBjIoYrJcYxLRO+F9ri+leVGoJcMW1uqr4r1Hq7vW5cyWrA43lFbpRvQ9XgNrnfLpIkO3i1emDBIA==}
engines: {node: ^16.0.0 || >=18.0.0}
@@ -8666,7 +8819,7 @@ packages:
dependencies:
'@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3)
'@typescript-eslint/utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3)
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
eslint: 8.53.0
ts-api-utils: 1.0.1(typescript@5.3.3)
typescript: 5.3.3
@@ -8686,7 +8839,27 @@ packages:
dependencies:
'@typescript-eslint/typescript-estree': 6.14.0(typescript@5.3.3)
'@typescript-eslint/utils': 6.14.0(eslint@8.56.0)(typescript@5.3.3)
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
+ eslint: 8.56.0
+ ts-api-utils: 1.0.1(typescript@5.3.3)
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@typescript-eslint/type-utils@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ peerDependencies:
+ eslint: ^7.0.0 || ^8.0.0
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+ '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ debug: 4.3.4(supports-color@5.5.0)
eslint: 8.56.0
ts-api-utils: 1.0.1(typescript@5.3.3)
typescript: 5.3.3
@@ -8704,6 +8877,11 @@ packages:
engines: {node: ^16.0.0 || >=18.0.0}
dev: true
+ /@typescript-eslint/types@6.19.0:
+ resolution: {integrity: sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ dev: true
+
/@typescript-eslint/typescript-estree@6.11.0(typescript@5.3.3):
resolution: {integrity: sha512-Aezzv1o2tWJwvZhedzvD5Yv7+Lpu1by/U1LZ5gLc4tCx8jUmuSCMioPFRjliN/6SJIvY6HpTtJIWubKuYYYesQ==}
engines: {node: ^16.0.0 || >=18.0.0}
@@ -8715,7 +8893,7 @@ packages:
dependencies:
'@typescript-eslint/types': 6.11.0
'@typescript-eslint/visitor-keys': 6.11.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
@@ -8736,7 +8914,7 @@ packages:
dependencies:
'@typescript-eslint/types': 6.14.0
'@typescript-eslint/visitor-keys': 6.14.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
@@ -8746,6 +8924,28 @@ packages:
- supports-color
dev: true
+ /@typescript-eslint/typescript-estree@6.19.0(typescript@5.3.3):
+ resolution: {integrity: sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ '@typescript-eslint/types': 6.19.0
+ '@typescript-eslint/visitor-keys': 6.19.0
+ debug: 4.3.4(supports-color@5.5.0)
+ globby: 11.1.0
+ is-glob: 4.0.3
+ minimatch: 9.0.3
+ semver: 7.5.4
+ ts-api-utils: 1.0.1(typescript@5.3.3)
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/@typescript-eslint/utils@6.11.0(eslint@8.53.0)(typescript@5.3.3):
resolution: {integrity: sha512-p23ibf68fxoZy605dc0dQAEoUsoiNoP3MD9WQGiHLDuTSOuqoTsa4oAy+h3KDkTcxbbfOtUjb9h3Ta0gT4ug2g==}
engines: {node: ^16.0.0 || >=18.0.0}
@@ -8784,6 +8984,25 @@ packages:
- typescript
dev: true
+ /@typescript-eslint/utils@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ peerDependencies:
+ eslint: ^7.0.0 || ^8.0.0
+ dependencies:
+ '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0)
+ '@types/json-schema': 7.0.12
+ '@types/semver': 7.5.6
+ '@typescript-eslint/scope-manager': 6.19.0
+ '@typescript-eslint/types': 6.19.0
+ '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+ eslint: 8.56.0
+ semver: 7.5.4
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+ dev: true
+
/@typescript-eslint/visitor-keys@6.11.0:
resolution: {integrity: sha512-+SUN/W7WjBr05uRxPggJPSzyB8zUpaYo2hByKasWbqr3PM8AXfZt8UHdNpBS1v9SA62qnSSMF3380SwDqqprgQ==}
engines: {node: ^16.0.0 || >=18.0.0}
@@ -8800,6 +9019,14 @@ packages:
eslint-visitor-keys: 3.4.3
dev: true
+ /@typescript-eslint/visitor-keys@6.19.0:
+ resolution: {integrity: sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ dependencies:
+ '@typescript-eslint/types': 6.19.0
+ eslint-visitor-keys: 3.4.3
+ dev: true
+
/@ungap/structured-clone@1.2.0:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
dev: true
@@ -9193,7 +9420,7 @@ packages:
engines: {node: '>= 6.0.0'}
requiresBuild: true
dependencies:
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -9201,7 +9428,7 @@ packages:
resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==}
engines: {node: '>= 14'}
dependencies:
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
dev: false
@@ -9587,7 +9814,7 @@ packages:
resolution: {integrity: sha512-TAlMYvOuwGyLK3PfBb5WKBXZmXz2fVCgv23d6zZFdle/q3gPjmxBaeuC0pY0Dzs5PWMSgfqqEZkrye19GlDTgw==}
dependencies:
archy: 1.0.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
fastq: 1.15.0
transitivePeerDependencies:
- supports-color
@@ -11036,7 +11263,6 @@ packages:
dependencies:
ms: 2.1.2
supports-color: 5.5.0
- dev: true
/debug@4.3.4(supports-color@8.1.1):
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
@@ -11049,6 +11275,7 @@ packages:
dependencies:
ms: 2.1.2
supports-color: 8.1.1
+ dev: true
/decamelize-keys@1.1.1:
resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==}
@@ -11265,7 +11492,7 @@ packages:
hasBin: true
dependencies:
address: 1.2.2
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
dev: true
@@ -11589,7 +11816,7 @@ packages:
peerDependencies:
esbuild: '>=0.12 <1'
dependencies:
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
esbuild: 0.18.20
transitivePeerDependencies:
- supports-color
@@ -11806,6 +12033,35 @@ packages:
- supports-color
dev: true
+ /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0):
+ resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: '*'
+ eslint-import-resolver-node: '*'
+ eslint-import-resolver-typescript: '*'
+ eslint-import-resolver-webpack: '*'
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+ eslint:
+ optional: true
+ eslint-import-resolver-node:
+ optional: true
+ eslint-import-resolver-typescript:
+ optional: true
+ eslint-import-resolver-webpack:
+ optional: true
+ dependencies:
+ '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ debug: 3.2.7(supports-color@8.1.1)
+ eslint: 8.56.0
+ eslint-import-resolver-node: 0.3.9
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.11.0)(eslint@8.53.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
@@ -11876,6 +12132,41 @@ packages:
- supports-color
dev: true
+ /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0):
+ resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+ dependencies:
+ '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ array-includes: 3.1.7
+ array.prototype.findlastindex: 1.2.3
+ array.prototype.flat: 1.3.2
+ array.prototype.flatmap: 1.3.2
+ debug: 3.2.7(supports-color@8.1.1)
+ doctrine: 2.1.0
+ eslint: 8.56.0
+ eslint-import-resolver-node: 0.3.9
+ eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0)
+ hasown: 2.0.0
+ is-core-module: 2.13.1
+ is-glob: 4.0.3
+ minimatch: 3.1.2
+ object.fromentries: 2.0.7
+ object.groupby: 1.0.1
+ object.values: 1.1.7
+ semver: 6.3.1
+ tsconfig-paths: 3.15.0
+ transitivePeerDependencies:
+ - eslint-import-resolver-typescript
+ - eslint-import-resolver-webpack
+ - supports-color
+ dev: true
+
/eslint-plugin-vue@9.19.2(eslint@8.56.0):
resolution: {integrity: sha512-CPDqTOG2K4Ni2o4J5wixkLVNwgctKXFu6oBpVJlpNq7f38lh9I80pRTouZSJ2MAebPJlINU/KTFSXyQfBUlymA==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -11927,7 +12218,7 @@ packages:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.3
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -11974,7 +12265,7 @@ packages:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.3
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -12604,7 +12895,7 @@ packages:
debug:
optional: true
dependencies:
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
/for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -13160,7 +13451,6 @@ packages:
/has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
- dev: true
/has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
@@ -13298,7 +13588,7 @@ packages:
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
dev: false
@@ -13360,7 +13650,7 @@ packages:
engines: {node: '>= 6.0.0'}
dependencies:
agent-base: 5.1.1
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
dev: true
@@ -13370,7 +13660,7 @@ packages:
engines: {node: '>= 6'}
dependencies:
agent-base: 6.0.2
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -13379,7 +13669,7 @@ packages:
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
dev: false
@@ -13389,7 +13679,7 @@ packages:
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
dev: false
@@ -13549,7 +13839,7 @@ packages:
dependencies:
'@ioredis/commands': 1.2.0
cluster-key-slot: 1.1.2
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
@@ -13990,7 +14280,7 @@ packages:
resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
engines: {node: '>=10'}
dependencies:
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.0
source-map: 0.6.1
transitivePeerDependencies:
@@ -14044,7 +14334,7 @@ packages:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
chalk: 4.1.2
co: 4.6.0
dedent: 1.3.0
@@ -14133,6 +14423,46 @@ packages:
- supports-color
dev: true
+ /jest-config@29.7.0(@types/node@20.11.5):
+ resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==}
+ engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ peerDependencies:
+ '@types/node': '*'
+ ts-node: '>=9.0.0'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ ts-node:
+ optional: true
+ dependencies:
+ '@babel/core': 7.22.11
+ '@jest/test-sequencer': 29.7.0
+ '@jest/types': 29.6.3
+ '@types/node': 20.11.5
+ babel-jest: 29.7.0(@babel/core@7.22.11)
+ chalk: 4.1.2
+ ci-info: 3.7.1
+ deepmerge: 4.2.2
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ jest-circus: 29.7.0
+ jest-environment-node: 29.7.0
+ jest-get-type: 29.6.3
+ jest-regex-util: 29.6.3
+ jest-resolve: 29.7.0
+ jest-runner: 29.7.0
+ jest-util: 29.7.0
+ jest-validate: 29.7.0
+ micromatch: 4.0.5
+ parse-json: 5.2.0
+ pretty-format: 29.7.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+ dev: true
+
/jest-diff@28.1.3:
resolution: {integrity: sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==}
engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
@@ -14188,7 +14518,7 @@ packages:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
jest-mock: 29.7.0
jest-util: 29.7.0
dev: true
@@ -14218,7 +14548,7 @@ packages:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.6
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -14279,7 +14609,7 @@ packages:
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
dependencies:
'@jest/types': 27.5.1
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
dev: true
/jest-mock@29.7.0:
@@ -14342,7 +14672,7 @@ packages:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@@ -14373,7 +14703,7 @@ packages:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
chalk: 4.1.2
cjs-module-lexer: 1.2.2
collect-v8-coverage: 1.0.1
@@ -14425,7 +14755,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
chalk: 4.1.2
ci-info: 3.7.1
graceful-fs: 4.2.11
@@ -14450,7 +14780,7 @@ packages:
dependencies:
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -14469,7 +14799,7 @@ packages:
resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
- '@types/node': 20.10.5
+ '@types/node': 20.11.5
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -14667,7 +14997,7 @@ packages:
resolution: {integrity: sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg==}
engines: {node: '>=10'}
dependencies:
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
rfdc: 1.3.0
uri-js: 4.4.1
transitivePeerDependencies:
@@ -17278,7 +17608,7 @@ packages:
engines: {node: '>=8.16.0'}
dependencies:
'@types/mime-types': 2.1.4
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
extract-zip: 1.7.0
https-proxy-agent: 4.0.0
mime: 2.6.0
@@ -18275,7 +18605,7 @@ packages:
dependencies:
'@hapi/hoek': 10.0.1
'@hapi/wreck': 18.0.1
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
joi: 17.7.0
transitivePeerDependencies:
- supports-color
@@ -18475,7 +18805,7 @@ packages:
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
socks: 2.7.1
transitivePeerDependencies:
- supports-color
@@ -18628,7 +18958,7 @@ packages:
arg: 5.0.2
bluebird: 3.7.2
check-more-types: 2.24.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
execa: 5.1.1
lazy-ass: 1.6.0
ps-tree: 1.2.0
@@ -18892,7 +19222,6 @@ packages:
engines: {node: '>=4'}
dependencies:
has-flag: 3.0.0
- dev: true
/supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
@@ -19515,7 +19844,7 @@ packages:
chalk: 4.1.2
cli-highlight: 2.1.11
date-fns: 2.30.0
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
dotenv: 16.0.3
glob: 8.1.0
ioredis: 5.3.2
@@ -19880,7 +20209,7 @@ packages:
hasBin: true
dependencies:
cac: 6.7.14
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
mlly: 1.4.0
pathe: 1.1.1
picocolors: 1.0.0
@@ -19992,7 +20321,7 @@ packages:
acorn-walk: 8.2.0
cac: 6.7.14
chai: 4.3.10
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
happy-dom: 10.0.3
local-pkg: 0.4.3
magic-string: 0.30.3
@@ -20074,7 +20403,7 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
dependencies:
- debug: 4.3.4(supports-color@8.1.1)
+ debug: 4.3.4(supports-color@5.5.0)
eslint: 8.56.0
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3b2ecec7fd..3a03a58253 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,3 +4,4 @@ packages:
- 'packages/sw'
- 'packages/misskey-js'
- 'packages/misskey-js/generator'
+ - 'packages/misskey-reversi'