Compare commits
	
		
			20 Commits
		
	
	
		
			2024.7.0-r
			...
			reversi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 8dc2f1f49d | ||
|   | 353098f576 | ||
|   | 4c43ee4b53 | ||
|   | 037a1daa79 | ||
|   | 1259fabd7f | ||
|   | 58f4d5d790 | ||
|   | 7ca93893ca | ||
|   | fbad40bb9b | ||
|   | 7794e7ae2f | ||
|   | a78013d0e5 | ||
|   | 768d0bdc00 | ||
|   | 36450d7fac | ||
|   | 4e1fb618b8 | ||
|   | 1a25401452 | ||
|   | 8ef70283fa | ||
|   | 22bb79b3fd | ||
|   | 38e7054f06 | ||
|   | d6c8e520f8 | ||
|   | 1b5ad63672 | ||
|   | 5bcdd6e849 | 
							
								
								
									
										35
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										35
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -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; | ||||
|   | ||||
| @@ -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: "対戦相手を探しています" | ||||
|   | ||||
							
								
								
									
										22
									
								
								packages/backend/migration/1705475608437-reversi.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								packages/backend/migration/1705475608437-reversi.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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") `); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										18
									
								
								packages/backend/migration/1705654039457-reversi-2.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								packages/backend/migration/1705654039457-reversi-2.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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"`); | ||||
|     } | ||||
| } | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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); | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										411
									
								
								packages/backend/src/core/ReversiService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										411
									
								
								packages/backend/src/core/ReversiService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										115
									
								
								packages/backend/src/core/entities/ReversiGameEntityService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								packages/backend/src/core/entities/ReversiGameEntityService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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))); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -79,5 +79,6 @@ export const DI = { | ||||
| 	flashLikesRepository: Symbol('flashLikesRepository'), | ||||
| 	userMemosRepository: Symbol('userMemosRepository'), | ||||
| 	bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), | ||||
| 	reversiGamesRepository: Symbol('reversiGamesRepository'), | ||||
| 	//#endregion | ||||
| }; | ||||
|   | ||||
| @@ -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]>; | ||||
|   | ||||
| @@ -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 {} | ||||
|   | ||||
							
								
								
									
										127
									
								
								packages/backend/src/models/ReversiGame.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								packages/backend/src/models/ReversiGame.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| } | ||||
| @@ -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>; | ||||
|   | ||||
							
								
								
									
										234
									
								
								packages/backend/src/models/json-schema/reversi-game.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								packages/backend/src/models/json-schema/reversi-game.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -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, | ||||
| ]; | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 {} | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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; | ||||
| 			}); | ||||
|   | ||||
| @@ -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); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										61
									
								
								packages/backend/src/server/api/endpoints/reversi/games.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								packages/backend/src/server/api/endpoints/reversi/games.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -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); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										66
									
								
								packages/backend/src/server/api/endpoints/reversi/match.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								packages/backend/src/server/api/endpoints/reversi/match.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -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); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -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); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -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}`); | ||||
|   | ||||
							
								
								
									
										130
									
								
								packages/backend/src/server/api/stream/channels/reversi-game.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								packages/backend/src/server/api/stream/channels/reversi-game.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										52
									
								
								packages/backend/src/server/api/stream/channels/reversi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								packages/backend/src/server/api/stream/channels/reversi.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								packages/frontend/assets/reversi/logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								packages/frontend/assets/reversi/logo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 94 KiB | 
| @@ -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", | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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); | ||||
| 			}, | ||||
| 		}); | ||||
| 	}; | ||||
|   | ||||
| @@ -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', { | ||||
|   | ||||
| @@ -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')), | ||||
| @@ -523,18 +524,26 @@ const routes = [{ | ||||
| 	path: '/timeline/antenna/:antennaId', | ||||
| 	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')), | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -123,7 +123,7 @@ function onGameEnd() { | ||||
|  | ||||
| definePageMetadata({ | ||||
| 	title: i18n.ts.bubbleGame, | ||||
| 	icon: 'ti ti-apple', | ||||
| 	icon: 'ti ti-device-gamepad', | ||||
| }); | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -7,11 +7,18 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader/></template> | ||||
| 	<MkSpacer :contentMax="800"> | ||||
| 		<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> | ||||
| </template> | ||||
|   | ||||
							
								
								
									
										428
									
								
								packages/frontend/src/pages/reversi/game.board.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										428
									
								
								packages/frontend/src/pages/reversi/game.board.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										236
									
								
								packages/frontend/src/pages/reversi/game.setting.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										236
									
								
								packages/frontend/src/pages/reversi/game.setting.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										68
									
								
								packages/frontend/src/pages/reversi/game.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								packages/frontend/src/pages/reversi/game.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										271
									
								
								packages/frontend/src/pages/reversi/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										271
									
								
								packages/frontend/src/pages/reversi/index.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
| @@ -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/], | ||||
| 			}, | ||||
| 		}, | ||||
|  | ||||
|   | ||||
| @@ -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 } | ||||
| @@ -2596,6 +2608,42 @@ type ResetPasswordRequest = operations['reset-password']['requestBody']['content | ||||
| // @public (undocumented) | ||||
| 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']; | ||||
|  | ||||
|   | ||||
| @@ -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>>; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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 }; | ||||
| } | ||||
|   | ||||
| @@ -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']; | ||||
|   | ||||
| @@ -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']; | ||||
|   | ||||
| @@ -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']; | ||||
|         }; | ||||
|       }; | ||||
|     }; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
|   | ||||
							
								
								
									
										26
									
								
								packages/misskey-reversi/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								packages/misskey-reversi/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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": { | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										216
									
								
								packages/misskey-reversi/src/game.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								packages/misskey-reversi/src/game.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										7
									
								
								packages/misskey-reversi/src/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/misskey-reversi/src/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import { Game } from './game.js'; | ||||
|  | ||||
| export { | ||||
| 	Game, | ||||
| }; | ||||
|  | ||||
| export * as maps from './maps.js'; | ||||
							
								
								
									
										715
									
								
								packages/misskey-reversi/src/maps.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										715
									
								
								packages/misskey-reversi/src/maps.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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---', | ||||
| 		'-------- --------', | ||||
| 		'-------- --------', | ||||
| 		'-------- --------' | ||||
| 	] | ||||
| }; | ||||
							
								
								
									
										33
									
								
								packages/misskey-reversi/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								packages/misskey-reversi/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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/**/*" | ||||
| 	] | ||||
| } | ||||
							
								
								
									
										479
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										479
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -4,3 +4,4 @@ packages: | ||||
|  - 'packages/sw' | ||||
|  - 'packages/misskey-js' | ||||
|  - 'packages/misskey-js/generator' | ||||
|  - 'packages/misskey-reversi' | ||||
|   | ||||
		Reference in New Issue
	
	Block a user