Merge pull request MisskeyIO#381 from merge-upstream
This commit is contained in:
		
							
								
								
									
										20
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -9795,6 +9795,26 @@ export interface Locale extends ILocale {
 | 
			
		||||
         * 対局がキャンセルされました
 | 
			
		||||
         */
 | 
			
		||||
        "gameCanceled": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * 開始時に対局をタイムラインに投稿
 | 
			
		||||
         */
 | 
			
		||||
        "shareToTlTheGameWhenStart": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * 対局を開始しました! #MisskeyReversi
 | 
			
		||||
         */
 | 
			
		||||
        "iStartedAGame": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * 相手が設定を変更しました
 | 
			
		||||
         */
 | 
			
		||||
        "opponentHasSettingsChanged": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * 変則許可 (完全フリー)
 | 
			
		||||
         */
 | 
			
		||||
        "allowIrregularRules": string;
 | 
			
		||||
        /**
 | 
			
		||||
         * 変則なし
 | 
			
		||||
         */
 | 
			
		||||
        "disallowIrregularRules": string;
 | 
			
		||||
    };
 | 
			
		||||
    "_offlineScreen": {
 | 
			
		||||
        /**
 | 
			
		||||
 
 | 
			
		||||
@@ -2611,6 +2611,11 @@ _reversi:
 | 
			
		||||
  freeMatch: "フリーマッチ"
 | 
			
		||||
  lookingForPlayer: "対戦相手を探しています"
 | 
			
		||||
  gameCanceled: "対局がキャンセルされました"
 | 
			
		||||
  shareToTlTheGameWhenStart: "開始時に対局をタイムラインに投稿"
 | 
			
		||||
  iStartedAGame: "対局を開始しました! #MisskeyReversi"
 | 
			
		||||
  opponentHasSettingsChanged: "相手が設定を変更しました"
 | 
			
		||||
  allowIrregularRules: "変則許可 (完全フリー)"
 | 
			
		||||
  disallowIrregularRules: "変則なし"
 | 
			
		||||
 | 
			
		||||
_offlineScreen:
 | 
			
		||||
  title: "オフライン - サーバーに接続できません"
 | 
			
		||||
 
 | 
			
		||||
@@ -380,8 +380,11 @@ hcaptcha: "hCaptcha(キャプチャ)"
 | 
			
		||||
enableHcaptcha: "hCaptcha(キャプチャ)をつけとく"
 | 
			
		||||
hcaptchaSiteKey: "サイトキー"
 | 
			
		||||
hcaptchaSecretKey: "シークレットキー"
 | 
			
		||||
mcaptcha: "mCaptcha"
 | 
			
		||||
enableMcaptcha: "hCaptcha(キャプチャ)をつけとく"
 | 
			
		||||
mcaptchaSiteKey: "サイトキー"
 | 
			
		||||
mcaptchaSecretKey: "シークレットキー"
 | 
			
		||||
mcaptchaInstanceUrl: "mCaptchaのインスタンスのURL"
 | 
			
		||||
recaptcha: "reCAPTCHA"
 | 
			
		||||
enableRecaptcha: "reCAPTCHA(リキャプチャ)を有効にする"
 | 
			
		||||
recaptchaSiteKey: "サイトキー"
 | 
			
		||||
@@ -629,6 +632,7 @@ medium: "中"
 | 
			
		||||
small: "小"
 | 
			
		||||
generateAccessToken: "アクセストークンの発行"
 | 
			
		||||
permission: "権限"
 | 
			
		||||
adminPermission: "管理者権限"
 | 
			
		||||
enableAll: "全部使えるようにする"
 | 
			
		||||
disableAll: "全部使えへんようにする"
 | 
			
		||||
tokenRequested: "アカウントへのアクセス許してやったらどうや"
 | 
			
		||||
@@ -1055,6 +1059,8 @@ limitWidthOfReaction: "ツッコミの最大横幅を制限して、ちっさく
 | 
			
		||||
noteIdOrUrl: "ノートIDかURL"
 | 
			
		||||
video: "動画"
 | 
			
		||||
videos: "動画"
 | 
			
		||||
audio: "音声"
 | 
			
		||||
audioFiles: "音声"
 | 
			
		||||
dataSaver: "データケチケチ"
 | 
			
		||||
accountMigration: "アカウントのお引っ越し"
 | 
			
		||||
accountMoved: "このユーザーはさらのアカウントに引っ越したで:"
 | 
			
		||||
@@ -1187,7 +1193,25 @@ seasonalScreenEffect: "季節にあった画面の動き"
 | 
			
		||||
decorate: "デコる"
 | 
			
		||||
addMfmFunction: "装飾つける"
 | 
			
		||||
enableQuickAddMfmFunction: "ややこしいMFMのピッカーを出す"
 | 
			
		||||
bubbleGame: "バブルゲーム"
 | 
			
		||||
sfx: "効果音"
 | 
			
		||||
soundWillBePlayed: "サウンドが再生されるで"
 | 
			
		||||
showReplay: "リプレイ見る"
 | 
			
		||||
replay: "リプレイ"
 | 
			
		||||
replaying: "リプレイ中"
 | 
			
		||||
ranking: "ランキング"
 | 
			
		||||
lastNDays: "直近{n}日"
 | 
			
		||||
backToTitle: "タイトルへ"
 | 
			
		||||
hemisphere: "住んでる地域"
 | 
			
		||||
withSensitive: "センシティブなファイルを含むノートを表示"
 | 
			
		||||
userSaysSomethingSensitive: "{name}のセンシティブなファイルを含む投稿"
 | 
			
		||||
enableHorizontalSwipe: "スワイプしてタブを切り替える"
 | 
			
		||||
_bubbleGame:
 | 
			
		||||
  howToPlay: "遊び方"
 | 
			
		||||
  _howToPlay:
 | 
			
		||||
    section1: "位置を調整してハコにモノを落とすで。"
 | 
			
		||||
    section2: "同じもんがくっついたら別のやつになって、スコアがもらえるで。"
 | 
			
		||||
    section3: "モノがハコからあふれたらゲームオーバーや。ハコからあふれんようにしながらモノを融合させてハイスコアを目指しいや!"
 | 
			
		||||
_announcement:
 | 
			
		||||
  forExistingUsers: "もうおるユーザーのみ"
 | 
			
		||||
  forExistingUsersDescription: "オンにしたらこのお知らせができた時点でおる人らにだけお知らせが行くで。切ったらこの知らせが行ったあとにアカウント作った人にもちゃんとお知らせが行くで。"
 | 
			
		||||
@@ -1558,6 +1582,13 @@ _achievements:
 | 
			
		||||
    _tutorialCompleted:
 | 
			
		||||
      title: "Misskeyひよっこ講座 修了証"
 | 
			
		||||
      description: "チュートリアル全部やった"
 | 
			
		||||
    _bubbleGameExplodingHead:
 | 
			
		||||
      title: "🤯"
 | 
			
		||||
      description: "バブルゲームで最も大きいモノを出した"
 | 
			
		||||
    _bubbleGameDoubleExplodingHead:
 | 
			
		||||
      title: "ダブル🤯"
 | 
			
		||||
      description: "バブルゲームで最も大きいモノを2つ同時に出した"
 | 
			
		||||
      flavor: "これくらいの おべんとばこに 🤯 🤯 ちょっとつめて"
 | 
			
		||||
_role:
 | 
			
		||||
  new: "ロールの作成"
 | 
			
		||||
  edit: "ロールの編集"
 | 
			
		||||
@@ -2410,6 +2441,51 @@ _dataSaver:
 | 
			
		||||
  _code:
 | 
			
		||||
    title: "コードハイライト"
 | 
			
		||||
    description: "MFMとかでコードハイライト記法が使われてるとき、タップするまで読み込まれへんくなるで。コードハイライトではハイライトする言語ごとにその決めてるファイルを読む必要はあんねんな。けどな、それは自動で読み込まれなくなるから、通信量を少なくできることができるねん。"
 | 
			
		||||
_hemisphere:
 | 
			
		||||
  N: "北半球"
 | 
			
		||||
  S: "南半球"
 | 
			
		||||
  caption: "一部のクライアント設定で、季節を判定するのに使用するで。"
 | 
			
		||||
_reversi:
 | 
			
		||||
  reversi: "リバーシ"
 | 
			
		||||
  gameSettings: "対局の設定"
 | 
			
		||||
  chooseBoard: "ボードを選択"
 | 
			
		||||
  blackOrWhite: "先行/後攻"
 | 
			
		||||
  blackIs: "{name}が黒(先行)"
 | 
			
		||||
  rules: "ルール"
 | 
			
		||||
  thisGameIsStartedSoon: "対局、そろそろ開始されるで。"
 | 
			
		||||
  waitingForOther: "相手の準備が完了するのを待ってんで。"
 | 
			
		||||
  waitingForMe: "あんさんの準備が完了すんのを待ってんで"
 | 
			
		||||
  waitingBoth: "準備してなー"
 | 
			
		||||
  ready: "準備完了"
 | 
			
		||||
  cancelReady: "準備を再開"
 | 
			
		||||
  opponentTurn: "相手のターンやで"
 | 
			
		||||
  myTurn: "あんさんのターンや"
 | 
			
		||||
  turnOf: "{name}のターンやで"
 | 
			
		||||
  pastTurnOf: "{name}のターン"
 | 
			
		||||
  surrender: "投了"
 | 
			
		||||
  surrendered: "投了により"
 | 
			
		||||
  timeout: "時間切れ"
 | 
			
		||||
  drawn: "引き分け"
 | 
			
		||||
  won: "{name}の勝ち"
 | 
			
		||||
  black: "黒"
 | 
			
		||||
  white: "白"
 | 
			
		||||
  total: "合計"
 | 
			
		||||
  turnCount: "{count}ターン目"
 | 
			
		||||
  myGames: "自分の対局"
 | 
			
		||||
  allGames: "みんなの対局"
 | 
			
		||||
  ended: "終了"
 | 
			
		||||
  playing: "対局中"
 | 
			
		||||
  isLlotheo: "石の少ない方が勝ち(ロセオ)"
 | 
			
		||||
  loopedMap: "ループマップ"
 | 
			
		||||
  canPutEverywhere: "どこでも置けるモード"
 | 
			
		||||
  timeLimitForEachTurn: "1ターンの時間制限"
 | 
			
		||||
  freeMatch: "フリーマッチ"
 | 
			
		||||
  lookingForPlayer: "対戦相手を探してるで"
 | 
			
		||||
  gameCanceled: "対局がキャンセルされたわ"
 | 
			
		||||
  shareToTlTheGameWhenStart: "初めの時に対局をタイムラインに投稿するで"
 | 
			
		||||
  iStartedAGame: "対局し始めたで! #MisskeyReversi"
 | 
			
		||||
  opponentHasSettingsChanged: "相手が設定変えたで"
 | 
			
		||||
_offlineScreen:
 | 
			
		||||
  title: "オフライン - サーバーに接続できひんで"
 | 
			
		||||
  header: "サーバーに接続できへんわ"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								packages/backend/migration/1706081514499-reversi-6.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								packages/backend/migration/1706081514499-reversi-6.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export class Reversi61706081514499 {
 | 
			
		||||
    name = 'Reversi61706081514499'
 | 
			
		||||
 | 
			
		||||
    async up(queryRunner) {
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "reversi_game" ADD "noIrregularRules" boolean NOT NULL DEFAULT false`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async down(queryRunner) {
 | 
			
		||||
        await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "noIrregularRules"`);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -81,13 +81,13 @@
 | 
			
		||||
		"@fastify/view": "8.2.0",
 | 
			
		||||
		"@misskey-dev/sharp-read-bmp": "^1.1.1",
 | 
			
		||||
		"@misskey-dev/summaly": "^5.0.3",
 | 
			
		||||
		"@nestjs/common": "10.2.10",
 | 
			
		||||
		"@nestjs/core": "10.2.10",
 | 
			
		||||
		"@nestjs/testing": "10.2.10",
 | 
			
		||||
		"@nestjs/common": "10.3.1",
 | 
			
		||||
		"@nestjs/core": "10.3.1",
 | 
			
		||||
		"@nestjs/testing": "10.3.1",
 | 
			
		||||
		"@peertube/http-signature": "1.7.0",
 | 
			
		||||
		"@simplewebauthn/server": "9.0.0",
 | 
			
		||||
		"@sinonjs/fake-timers": "11.2.2",
 | 
			
		||||
		"@smithy/node-http-handler": "2.1.10",
 | 
			
		||||
		"@smithy/node-http-handler": "2.3.1",
 | 
			
		||||
		"@swc/cli": "0.1.65",
 | 
			
		||||
		"@swc/core": "1.3.105",
 | 
			
		||||
		"@twemoji/parser": "15.0.0",
 | 
			
		||||
@@ -98,7 +98,7 @@
 | 
			
		||||
		"bcryptjs": "2.4.3",
 | 
			
		||||
		"blurhash": "2.0.5",
 | 
			
		||||
		"body-parser": "1.20.2",
 | 
			
		||||
		"bullmq": "5.1.4",
 | 
			
		||||
		"bullmq": "5.1.5",
 | 
			
		||||
		"cacheable-lookup": "7.0.0",
 | 
			
		||||
		"cbor": "9.0.1",
 | 
			
		||||
		"chalk": "5.3.0",
 | 
			
		||||
@@ -186,7 +186,7 @@
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@jest/globals": "29.7.0",
 | 
			
		||||
		"@misskey-dev/eslint-plugin": "1.0.0",
 | 
			
		||||
		"@nestjs/platform-express": "10.3.0",
 | 
			
		||||
		"@nestjs/platform-express": "10.3.1",
 | 
			
		||||
		"@simplewebauthn/types": "9.0.0",
 | 
			
		||||
		"@swc/jest": "0.2.31",
 | 
			
		||||
		"@types/accepts": "1.3.7",
 | 
			
		||||
@@ -204,7 +204,7 @@
 | 
			
		||||
		"@types/jsrsasign": "10.5.12",
 | 
			
		||||
		"@types/mime-types": "2.1.4",
 | 
			
		||||
		"@types/ms": "0.7.34",
 | 
			
		||||
		"@types/node": "20.11.5",
 | 
			
		||||
		"@types/node": "20.11.6",
 | 
			
		||||
		"@types/nodemailer": "6.4.14",
 | 
			
		||||
		"@types/oauth": "0.9.4",
 | 
			
		||||
		"@types/oauth2orize": "1.11.3",
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
 | 
			
		||||
import * as Redis from 'ioredis';
 | 
			
		||||
import { ModuleRef } from '@nestjs/core';
 | 
			
		||||
import * as Reversi from 'misskey-reversi';
 | 
			
		||||
import { LessThan, MoreThan } from 'typeorm';
 | 
			
		||||
import type {
 | 
			
		||||
	MiReversiGame,
 | 
			
		||||
	ReversiGamesRepository,
 | 
			
		||||
@@ -23,7 +24,7 @@ import { Serialized } from '@/types.js';
 | 
			
		||||
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
 | 
			
		||||
import type { OnModuleInit } from '@nestjs/common';
 | 
			
		||||
 | 
			
		||||
const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec
 | 
			
		||||
const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class ReversiService implements OnModuleInit {
 | 
			
		||||
@@ -84,44 +85,82 @@ export class ReversiService implements OnModuleInit {
 | 
			
		||||
			map: game.map,
 | 
			
		||||
			bw: game.bw,
 | 
			
		||||
			crc32: game.crc32,
 | 
			
		||||
			noIrregularRules: game.noIrregularRules,
 | 
			
		||||
		} satisfies Partial<MiReversiGame>;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@bindThis
 | 
			
		||||
	public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> {
 | 
			
		||||
	public async matchSpecificUser(me: MiUser, targetUser: MiUser, multiple = false): Promise<MiReversiGame | null> {
 | 
			
		||||
		if (targetUser.id === me.id) {
 | 
			
		||||
			throw new Error('You cannot match yourself.');
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (!multiple) {
 | 
			
		||||
			// 既にマッチしている対局が無いか探す(3分以内)
 | 
			
		||||
			const games = await this.reversiGamesRepository.find({
 | 
			
		||||
				where: [
 | 
			
		||||
					{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: me.id, user2Id: targetUser.id, isStarted: false },
 | 
			
		||||
					{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: targetUser.id, user2Id: me.id, isStarted: false },
 | 
			
		||||
				],
 | 
			
		||||
				relations: ['user1', 'user2'],
 | 
			
		||||
				order: { id: 'DESC' },
 | 
			
		||||
			});
 | 
			
		||||
			if (games.length > 0) {
 | 
			
		||||
				return games[0];
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//#region 相手から既に招待されてないか確認
 | 
			
		||||
		const invitations = await this.redisClient.zrange(
 | 
			
		||||
			`reversi:matchSpecific:${me.id}`,
 | 
			
		||||
			Date.now() - MATCHING_TIMEOUT_MS,
 | 
			
		||||
			Date.now() - INVITATION_TIMEOUT_MS,
 | 
			
		||||
			'+inf',
 | 
			
		||||
			'BYSCORE');
 | 
			
		||||
 | 
			
		||||
		if (invitations.includes(targetUser.id)) {
 | 
			
		||||
			await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
 | 
			
		||||
 | 
			
		||||
			const game = await this.matched(targetUser.id, me.id);
 | 
			
		||||
 | 
			
		||||
			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),
 | 
			
		||||
			const game = await this.matched(targetUser.id, me.id, {
 | 
			
		||||
				noIrregularRules: false,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			return null;
 | 
			
		||||
			return game;
 | 
			
		||||
		}
 | 
			
		||||
		//#endregion
 | 
			
		||||
 | 
			
		||||
		const redisPipeline = this.redisClient.pipeline();
 | 
			
		||||
		redisPipeline.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
 | 
			
		||||
		redisPipeline.expire(`reversi:matchSpecific:${targetUser.id}`, 120, 'NX');
 | 
			
		||||
		await redisPipeline.exec();
 | 
			
		||||
 | 
			
		||||
		this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
 | 
			
		||||
			user: await this.userEntityService.pack(me, targetUser),
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@bindThis
 | 
			
		||||
	public async matchAnyUser(me: MiUser): Promise<MiReversiGame | null> {
 | 
			
		||||
	public async matchAnyUser(me: MiUser, options: { noIrregularRules: boolean }, multiple = false): Promise<MiReversiGame | null> {
 | 
			
		||||
		if (!multiple) {
 | 
			
		||||
			// 既にマッチしている対局が無いか探す(3分以内)
 | 
			
		||||
			const games = await this.reversiGamesRepository.find({
 | 
			
		||||
				where: [
 | 
			
		||||
					{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: me.id, isStarted: false },
 | 
			
		||||
					{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user2Id: me.id, isStarted: false },
 | 
			
		||||
				],
 | 
			
		||||
				relations: ['user1', 'user2'],
 | 
			
		||||
				order: { id: 'DESC' },
 | 
			
		||||
			});
 | 
			
		||||
			if (games.length > 0) {
 | 
			
		||||
				return games[0];
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//#region まず自分宛ての招待を探す
 | 
			
		||||
		const invitations = await this.redisClient.zrange(
 | 
			
		||||
			`reversi:matchSpecific:${me.id}`,
 | 
			
		||||
			Date.now() - MATCHING_TIMEOUT_MS,
 | 
			
		||||
			Date.now() - INVITATION_TIMEOUT_MS,
 | 
			
		||||
			'+inf',
 | 
			
		||||
			'BYSCORE');
 | 
			
		||||
 | 
			
		||||
@@ -129,7 +168,9 @@ export class ReversiService implements OnModuleInit {
 | 
			
		||||
			const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
 | 
			
		||||
			await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
 | 
			
		||||
 | 
			
		||||
			const game = await this.matched(invitorId, me.id);
 | 
			
		||||
			const game = await this.matched(invitorId, me.id, {
 | 
			
		||||
				noIrregularRules: false,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			return game;
 | 
			
		||||
		}
 | 
			
		||||
@@ -137,23 +178,35 @@ export class ReversiService implements OnModuleInit {
 | 
			
		||||
 | 
			
		||||
		const matchings = await this.redisClient.zrange(
 | 
			
		||||
			'reversi:matchAny',
 | 
			
		||||
			Date.now() - MATCHING_TIMEOUT_MS,
 | 
			
		||||
			'+inf',
 | 
			
		||||
			'BYSCORE');
 | 
			
		||||
			0,
 | 
			
		||||
			2, // 自分自身のIDが入っている場合もあるので2つ取得
 | 
			
		||||
			'REV');
 | 
			
		||||
 | 
			
		||||
		const userIds = matchings.filter(id => id !== me.id);
 | 
			
		||||
		const items = matchings.filter(id => !id.startsWith(me.id));
 | 
			
		||||
 | 
			
		||||
		if (userIds.length > 0) {
 | 
			
		||||
			// pick random
 | 
			
		||||
			const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)];
 | 
			
		||||
		if (items.length > 0) {
 | 
			
		||||
			const [matchedUserId, option] = items[0].split(':');
 | 
			
		||||
 | 
			
		||||
			await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId);
 | 
			
		||||
			await this.redisClient.zrem('reversi:matchAny',
 | 
			
		||||
				me.id,
 | 
			
		||||
				matchedUserId,
 | 
			
		||||
				me.id + ':noIrregularRules',
 | 
			
		||||
				matchedUserId + ':noIrregularRules');
 | 
			
		||||
 | 
			
		||||
			const game = await this.matched(matchedUserId, me.id);
 | 
			
		||||
			const game = await this.matched(matchedUserId, me.id, {
 | 
			
		||||
				noIrregularRules: options.noIrregularRules || option === 'noIrregularRules',
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			return game;
 | 
			
		||||
		} else {
 | 
			
		||||
			await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id);
 | 
			
		||||
			const redisPipeline = this.redisClient.pipeline();
 | 
			
		||||
			if (options.noIrregularRules) {
 | 
			
		||||
				redisPipeline.zadd('reversi:matchAny', Date.now(), me.id + ':noIrregularRules');
 | 
			
		||||
			} else {
 | 
			
		||||
				redisPipeline.zadd('reversi:matchAny', Date.now(), me.id);
 | 
			
		||||
			}
 | 
			
		||||
			redisPipeline.expire('reversi:matchAny', 15, 'NX');
 | 
			
		||||
			await redisPipeline.exec();
 | 
			
		||||
			return null;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -165,7 +218,15 @@ export class ReversiService implements OnModuleInit {
 | 
			
		||||
 | 
			
		||||
	@bindThis
 | 
			
		||||
	public async matchAnyUserCancel(user: MiUser) {
 | 
			
		||||
		await this.redisClient.zrem('reversi:matchAny', user.id);
 | 
			
		||||
		await this.redisClient.zrem('reversi:matchAny', user.id, user.id + ':noIrregularRules');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@bindThis
 | 
			
		||||
	public async cleanOutdatedGames() {
 | 
			
		||||
		await this.reversiGamesRepository.delete({
 | 
			
		||||
			id: LessThan(this.idService.gen(Date.now() - 1000 * 60 * 10)),
 | 
			
		||||
			isStarted: false,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@bindThis
 | 
			
		||||
@@ -219,7 +280,7 @@ export class ReversiService implements OnModuleInit {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@bindThis
 | 
			
		||||
	private async matched(parentId: MiUser['id'], childId: MiUser['id']): Promise<MiReversiGame> {
 | 
			
		||||
	private async matched(parentId: MiUser['id'], childId: MiUser['id'], options: { noIrregularRules: boolean; }): Promise<MiReversiGame> {
 | 
			
		||||
		const game = await this.reversiGamesRepository.insert({
 | 
			
		||||
			id: this.idService.gen(),
 | 
			
		||||
			user1Id: parentId,
 | 
			
		||||
@@ -232,6 +293,7 @@ export class ReversiService implements OnModuleInit {
 | 
			
		||||
			map: Reversi.maps.eighteight.data,
 | 
			
		||||
			bw: 'random',
 | 
			
		||||
			isLlotheo: false,
 | 
			
		||||
			noIrregularRules: options.noIrregularRules,
 | 
			
		||||
		}).then(x => this.reversiGamesRepository.findOneOrFail({
 | 
			
		||||
			where: { id: x.identifiers[0].id },
 | 
			
		||||
			relations: ['user1', 'user2'],
 | 
			
		||||
@@ -333,7 +395,7 @@ export class ReversiService implements OnModuleInit {
 | 
			
		||||
	public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
 | 
			
		||||
		const invitations = await this.redisClient.zrange(
 | 
			
		||||
			`reversi:matchSpecific:${user.id}`,
 | 
			
		||||
			Date.now() - MATCHING_TIMEOUT_MS,
 | 
			
		||||
			Date.now() - INVITATION_TIMEOUT_MS,
 | 
			
		||||
			'+inf',
 | 
			
		||||
			'BYSCORE');
 | 
			
		||||
		return invitations;
 | 
			
		||||
 
 | 
			
		||||
@@ -60,6 +60,7 @@ export class ReversiGameEntityService {
 | 
			
		||||
			canPutEverywhere: game.canPutEverywhere,
 | 
			
		||||
			loopedBoard: game.loopedBoard,
 | 
			
		||||
			timeLimitForEachTurn: game.timeLimitForEachTurn,
 | 
			
		||||
			noIrregularRules: game.noIrregularRules,
 | 
			
		||||
			logs: game.logs,
 | 
			
		||||
			map: game.map,
 | 
			
		||||
		});
 | 
			
		||||
@@ -108,6 +109,7 @@ export class ReversiGameEntityService {
 | 
			
		||||
			canPutEverywhere: game.canPutEverywhere,
 | 
			
		||||
			loopedBoard: game.loopedBoard,
 | 
			
		||||
			timeLimitForEachTurn: game.timeLimitForEachTurn,
 | 
			
		||||
			noIrregularRules: game.noIrregularRules,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -113,6 +113,11 @@ export class MiReversiGame {
 | 
			
		||||
	})
 | 
			
		||||
	public bw: string;
 | 
			
		||||
 | 
			
		||||
	@Column('boolean', {
 | 
			
		||||
		default: false,
 | 
			
		||||
	})
 | 
			
		||||
	public noIrregularRules: boolean;
 | 
			
		||||
 | 
			
		||||
	@Column('boolean', {
 | 
			
		||||
		default: false,
 | 
			
		||||
	})
 | 
			
		||||
 
 | 
			
		||||
@@ -82,6 +82,10 @@ export const packedReversiGameLiteSchema = {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		noIrregularRules: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		isLlotheo: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
@@ -196,6 +200,10 @@ export const packedReversiGameDetailedSchema = {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		noIrregularRules: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
		},
 | 
			
		||||
		isLlotheo: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			optional: false, nullable: false,
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ import type Logger from '@/logger.js';
 | 
			
		||||
import { bindThis } from '@/decorators.js';
 | 
			
		||||
import { IdService } from '@/core/IdService.js';
 | 
			
		||||
import type { Config } from '@/config.js';
 | 
			
		||||
import { ReversiService } from '@/core/ReversiService.js';
 | 
			
		||||
import { QueueLoggerService } from '../QueueLoggerService.js';
 | 
			
		||||
import type * as Bull from 'bullmq';
 | 
			
		||||
 | 
			
		||||
@@ -32,6 +33,7 @@ export class CleanProcessorService {
 | 
			
		||||
		private roleAssignmentsRepository: RoleAssignmentsRepository,
 | 
			
		||||
 | 
			
		||||
		private queueLoggerService: QueueLoggerService,
 | 
			
		||||
		private reversiService: ReversiService,
 | 
			
		||||
		private idService: IdService,
 | 
			
		||||
	) {
 | 
			
		||||
		this.logger = this.queueLoggerService.logger.createSubLogger('clean');
 | 
			
		||||
@@ -65,6 +67,8 @@ export class CleanProcessorService {
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.reversiService.cleanOutdatedGames();
 | 
			
		||||
 | 
			
		||||
		this.logger.succ('Cleaned.');
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -43,7 +43,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		||||
	) {
 | 
			
		||||
		super(meta, paramDef, async (ps, me) => {
 | 
			
		||||
			const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
 | 
			
		||||
				.andWhere('game.isStarted = TRUE')
 | 
			
		||||
				.innerJoinAndSelect('game.user1', 'user1')
 | 
			
		||||
				.innerJoinAndSelect('game.user2', 'user2');
 | 
			
		||||
 | 
			
		||||
@@ -53,6 +52,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		||||
						.where('game.user1Id = :userId', { userId: me.id })
 | 
			
		||||
						.orWhere('game.user2Id = :userId', { userId: me.id });
 | 
			
		||||
				}));
 | 
			
		||||
			} else {
 | 
			
		||||
				query.andWhere('game.isStarted = TRUE');
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const games = await query.take(ps.limit).getMany();
 | 
			
		||||
 
 | 
			
		||||
@@ -37,6 +37,8 @@ export const paramDef = {
 | 
			
		||||
	type: 'object',
 | 
			
		||||
	properties: {
 | 
			
		||||
		userId: { type: 'string', format: 'misskey:id', nullable: true },
 | 
			
		||||
		noIrregularRules: { type: 'boolean', default: false },
 | 
			
		||||
		multiple: { type: 'boolean', default: false },
 | 
			
		||||
	},
 | 
			
		||||
	required: [],
 | 
			
		||||
} as const;
 | 
			
		||||
@@ -56,7 +58,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		||||
				throw err;
 | 
			
		||||
			}) : null;
 | 
			
		||||
 | 
			
		||||
			const game = target ? await this.reversiService.matchSpecificUser(me, target) : await this.reversiService.matchAnyUser(me);
 | 
			
		||||
			const game = target
 | 
			
		||||
				? await this.reversiService.matchSpecificUser(me, target, ps.multiple)
 | 
			
		||||
				: await this.reversiService.matchAnyUser(me, { noIrregularRules: ps.noIrregularRules }, ps.multiple);
 | 
			
		||||
 | 
			
		||||
			if (game == null) return;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 181 KiB  | 
@@ -40,7 +40,7 @@
 | 
			
		||||
		"chartjs-chart-matrix": "2.0.1",
 | 
			
		||||
		"chartjs-plugin-gradient": "0.6.1",
 | 
			
		||||
		"chartjs-plugin-zoom": "2.0.1",
 | 
			
		||||
		"chromatic": "10.3.1",
 | 
			
		||||
		"chromatic": "10.5.0",
 | 
			
		||||
		"compare-versions": "6.1.0",
 | 
			
		||||
		"cropperjs": "2.0.0-beta.4",
 | 
			
		||||
		"date-fns": "2.30.0",
 | 
			
		||||
@@ -51,7 +51,6 @@
 | 
			
		||||
		"insert-text-at-cursor": "0.3.0",
 | 
			
		||||
		"is-file-animated": "1.0.2",
 | 
			
		||||
		"json5": "2.2.3",
 | 
			
		||||
		"lodash.defaultsdeep": "4.6.1",
 | 
			
		||||
		"matter-js": "0.19.0",
 | 
			
		||||
		"mfm-js": "0.24.0",
 | 
			
		||||
		"misskey-bubble-game": "workspace:*",
 | 
			
		||||
@@ -101,10 +100,9 @@
 | 
			
		||||
		"@testing-library/vue": "8.0.1",
 | 
			
		||||
		"@types/escape-regexp": "0.0.3",
 | 
			
		||||
		"@types/estree": "1.0.5",
 | 
			
		||||
		"@types/lodash.defaultsdeep": "4.6.9",
 | 
			
		||||
		"@types/matter-js": "0.19.6",
 | 
			
		||||
		"@types/micromatch": "4.0.6",
 | 
			
		||||
		"@types/node": "20.11.5",
 | 
			
		||||
		"@types/node": "20.11.6",
 | 
			
		||||
		"@types/punycode": "2.1.3",
 | 
			
		||||
		"@types/sanitize-html": "2.9.5",
 | 
			
		||||
		"@types/throttle-debounce": "5.0.2",
 | 
			
		||||
@@ -125,7 +123,7 @@
 | 
			
		||||
		"happy-dom": "10.0.3",
 | 
			
		||||
		"intersection-observer": "0.12.2",
 | 
			
		||||
		"micromatch": "4.0.5",
 | 
			
		||||
		"msw": "2.1.3",
 | 
			
		||||
		"msw": "2.1.4",
 | 
			
		||||
		"msw-storybook-addon": "1.10.0",
 | 
			
		||||
		"nodemon": "3.0.3",
 | 
			
		||||
		"prettier": "3.2.4",
 | 
			
		||||
@@ -137,7 +135,7 @@
 | 
			
		||||
		"vite-plugin-turbosnap": "1.0.3",
 | 
			
		||||
		"vitest": "0.34.6",
 | 
			
		||||
		"vitest-fetch-mock": "0.2.2",
 | 
			
		||||
		"vue-eslint-parser": "9.4.1",
 | 
			
		||||
		"vue-eslint-parser": "9.4.2",
 | 
			
		||||
		"vue-tsc": "1.8.27"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
<template>
 | 
			
		||||
<div
 | 
			
		||||
	ref="rootEl"
 | 
			
		||||
	:class="[$style.transitionRoot]"
 | 
			
		||||
	:class="[$style.transitionRoot, { [$style.enableAnimation]: shouldAnimate }]"
 | 
			
		||||
	@touchstart.passive="touchStart"
 | 
			
		||||
	@touchmove.passive="touchMove"
 | 
			
		||||
	@touchend.passive="touchEnd"
 | 
			
		||||
@@ -44,6 +44,8 @@ const emit = defineEmits<{
 | 
			
		||||
	(ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value);
 | 
			
		||||
 | 
			
		||||
// ▼ しきい値 ▼ //
 | 
			
		||||
 | 
			
		||||
// スワイプと判定される最小の距離
 | 
			
		||||
@@ -188,7 +190,9 @@ watch(tabModel, (newTab, oldTab) => {
 | 
			
		||||
.transitionChildren {
 | 
			
		||||
	grid-area: 1 / 1 / 2 / 2;
 | 
			
		||||
	transform: translateX(var(--swipe));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.enableAnimation .transitionChildren {
 | 
			
		||||
	&.swipeAnimation_enterActive,
 | 
			
		||||
	&.swipeAnimation_leaveActive {
 | 
			
		||||
		transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
 | 
			
		||||
 
 | 
			
		||||
@@ -12,85 +12,96 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
			<div class="_gaps" :class="{ [$style.disallowInner]: isReady }">
 | 
			
		||||
				<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>
 | 
			
		||||
				<template v-if="game.noIrregularRules">
 | 
			
		||||
					<div>{{ i18n.ts._reversi.disallowIrregularRules }}</div>
 | 
			
		||||
				</template>
 | 
			
		||||
				<template v-else>
 | 
			
		||||
					<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 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>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<MkFolder :defaultOpen="true">
 | 
			
		||||
					<template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
 | 
			
		||||
					<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>
 | 
			
		||||
						<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.timeLimitForEachTurn }}</template>
 | 
			
		||||
					<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
 | 
			
		||||
					<MkFolder :defaultOpen="true">
 | 
			
		||||
						<template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template>
 | 
			
		||||
						<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
 | 
			
		||||
 | 
			
		||||
					<MkRadios v-model="game.timeLimitForEachTurn">
 | 
			
		||||
						<option :value="5">5{{ i18n.ts._time.second }}</option>
 | 
			
		||||
						<option :value="10">10{{ i18n.ts._time.second }}</option>
 | 
			
		||||
						<option :value="30">30{{ i18n.ts._time.second }}</option>
 | 
			
		||||
						<option :value="60">60{{ i18n.ts._time.second }}</option>
 | 
			
		||||
						<option :value="90">90{{ i18n.ts._time.second }}</option>
 | 
			
		||||
						<option :value="120">120{{ i18n.ts._time.second }}</option>
 | 
			
		||||
						<option :value="180">180{{ i18n.ts._time.second }}</option>
 | 
			
		||||
						<option :value="3600">3600{{ i18n.ts._time.second }}</option>
 | 
			
		||||
					</MkRadios>
 | 
			
		||||
				</MkFolder>
 | 
			
		||||
						<MkRadios v-model="game.timeLimitForEachTurn">
 | 
			
		||||
							<option :value="5">5{{ i18n.ts._time.second }}</option>
 | 
			
		||||
							<option :value="10">10{{ i18n.ts._time.second }}</option>
 | 
			
		||||
							<option :value="30">30{{ i18n.ts._time.second }}</option>
 | 
			
		||||
							<option :value="60">60{{ i18n.ts._time.second }}</option>
 | 
			
		||||
							<option :value="90">90{{ i18n.ts._time.second }}</option>
 | 
			
		||||
							<option :value="120">120{{ i18n.ts._time.second }}</option>
 | 
			
		||||
							<option :value="180">180{{ i18n.ts._time.second }}</option>
 | 
			
		||||
							<option :value="3600">3600{{ i18n.ts._time.second }}</option>
 | 
			
		||||
						</MkRadios>
 | 
			
		||||
					</MkFolder>
 | 
			
		||||
 | 
			
		||||
				<MkFolder :defaultOpen="true">
 | 
			
		||||
					<template #label>{{ i18n.ts._reversi.rules }}</template>
 | 
			
		||||
					<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 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>
 | 
			
		||||
				</template>
 | 
			
		||||
			</div>
 | 
			
		||||
		</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="cancel">{{ 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 style="text-align: center;" class="_gaps_s">
 | 
			
		||||
					<div v-if="opponentHasSettingsChanged" style="color: var(--warn);">{{ i18n.ts._reversi.opponentHasSettingsChanged }}</div>
 | 
			
		||||
					<div>
 | 
			
		||||
						<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="cancel">{{ 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>
 | 
			
		||||
					<div>
 | 
			
		||||
						<MkSwitch v-model="shareWhenStart">{{ i18n.ts._reversi.shareToTlTheGameWhenStart }}</MkSwitch>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</MkSpacer>
 | 
			
		||||
		</div>
 | 
			
		||||
@@ -124,6 +135,8 @@ const props = defineProps<{
 | 
			
		||||
	connection: Misskey.ChannelConnection;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const shareWhenStart = defineModel<boolean>('shareWhenStart', { default: false });
 | 
			
		||||
 | 
			
		||||
const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
 | 
			
		||||
 | 
			
		||||
const mapName = computed(() => {
 | 
			
		||||
@@ -142,6 +155,8 @@ const isOpReady = computed(() => {
 | 
			
		||||
	return false;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const opponentHasSettingsChanged = ref(false);
 | 
			
		||||
 | 
			
		||||
watch(() => game.value.bw, () => {
 | 
			
		||||
	updateSettings('bw');
 | 
			
		||||
});
 | 
			
		||||
@@ -190,6 +205,7 @@ async function cancel() {
 | 
			
		||||
 | 
			
		||||
function ready() {
 | 
			
		||||
	props.connection.send('ready', true);
 | 
			
		||||
	opponentHasSettingsChanged.value = false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function unready() {
 | 
			
		||||
@@ -212,6 +228,10 @@ function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof M
 | 
			
		||||
	if (userId === $i.id) return;
 | 
			
		||||
	if (game.value[key] === value) return;
 | 
			
		||||
	game.value[key] = value;
 | 
			
		||||
	if (isReady.value) {
 | 
			
		||||
		opponentHasSettingsChanged.value = true;
 | 
			
		||||
		unready();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function onMapCellClick(pos: number, pixel: string) {
 | 
			
		||||
 
 | 
			
		||||
@@ -5,12 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div v-if="game == null || (!game.isEnded && connection == null)"><MkLoading/></div>
 | 
			
		||||
<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection!"/>
 | 
			
		||||
<GameSetting v-else-if="!game.isStarted" v-model:shareWhenStart="shareWhenStart" :game="game" :connection="connection!"/>
 | 
			
		||||
<GameBoard v-else :game="game" :connection="connection"/>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { computed, watch, onMounted, shallowRef, onUnmounted } from 'vue';
 | 
			
		||||
import { computed, watch, onMounted, ref, shallowRef, onUnmounted } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import GameSetting from './game.setting.vue';
 | 
			
		||||
import GameBoard from './game.board.vue';
 | 
			
		||||
@@ -21,6 +21,7 @@ import { signinRequired } from '@/account.js';
 | 
			
		||||
import { useRouter } from '@/global/router/supplier.js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { useInterval } from '@/scripts/use-interval.js';
 | 
			
		||||
 | 
			
		||||
const $i = signinRequired();
 | 
			
		||||
 | 
			
		||||
@@ -32,17 +33,32 @@ const props = defineProps<{
 | 
			
		||||
 | 
			
		||||
const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null);
 | 
			
		||||
const connection = shallowRef<Misskey.ChannelConnection | null>(null);
 | 
			
		||||
const shareWhenStart = ref(false);
 | 
			
		||||
 | 
			
		||||
watch(() => props.gameId, () => {
 | 
			
		||||
	fetchGame();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function start(_game: Misskey.entities.ReversiGameDetailed) {
 | 
			
		||||
	if (game.value?.isStarted) return;
 | 
			
		||||
 | 
			
		||||
	if (shareWhenStart.value) {
 | 
			
		||||
		misskeyApi('notes/create', {
 | 
			
		||||
			text: i18n.ts._reversi.iStartedAGame + '\n' + location.href,
 | 
			
		||||
			visibility: 'home',
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	game.value = _game;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function fetchGame() {
 | 
			
		||||
	const _game = await misskeyApi('reversi/show-game', {
 | 
			
		||||
		gameId: props.gameId,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	game.value = _game;
 | 
			
		||||
	shareWhenStart.value = false;
 | 
			
		||||
 | 
			
		||||
	if (connection.value) {
 | 
			
		||||
		connection.value.dispose();
 | 
			
		||||
@@ -52,7 +68,7 @@ async function fetchGame() {
 | 
			
		||||
			gameId: game.value.id,
 | 
			
		||||
		});
 | 
			
		||||
		connection.value.on('started', x => {
 | 
			
		||||
			game.value = x.game;
 | 
			
		||||
			start(x.game);
 | 
			
		||||
		});
 | 
			
		||||
		connection.value.on('canceled', x => {
 | 
			
		||||
			connection.value?.dispose();
 | 
			
		||||
@@ -68,6 +84,25 @@ async function fetchGame() {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 通信を取りこぼした場合の救済
 | 
			
		||||
useInterval(async () => {
 | 
			
		||||
	if (game.value == null) return;
 | 
			
		||||
	if (game.value.isStarted) return;
 | 
			
		||||
 | 
			
		||||
	const _game = await misskeyApi('reversi/show-game', {
 | 
			
		||||
		gameId: props.gameId,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (_game.isStarted) {
 | 
			
		||||
		start(_game);
 | 
			
		||||
	} else {
 | 
			
		||||
		game.value = _game;
 | 
			
		||||
	}
 | 
			
		||||
}, 1000 * 10, {
 | 
			
		||||
	immediate: false,
 | 
			
		||||
	afterMounted: true,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	fetchGame();
 | 
			
		||||
});
 | 
			
		||||
@@ -78,10 +113,6 @@ onUnmounted(() => {
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const headerActions = computed(() => []);
 | 
			
		||||
 | 
			
		||||
const headerTabs = computed(() => []);
 | 
			
		||||
 | 
			
		||||
definePageMetadata(computed(() => ({
 | 
			
		||||
	title: 'Reversi',
 | 
			
		||||
	icon: 'ti ti-device-gamepad',
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
			<MkPagination :pagination="myGamesPagination" :disableAutoLoad="true">
 | 
			
		||||
				<template #default="{ items }">
 | 
			
		||||
					<div :class="$style.gamePreviews">
 | 
			
		||||
						<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
 | 
			
		||||
						<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isStarted && !g.isEnded && $style.gamePreviewWaiting, g.isStarted && !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
 | 
			
		||||
							<div :class="$style.gamePreviewPlayers">
 | 
			
		||||
								<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
 | 
			
		||||
								<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
 | 
			
		||||
@@ -45,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
								<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
 | 
			
		||||
							</div>
 | 
			
		||||
							<div :class="$style.gamePreviewFooter">
 | 
			
		||||
								<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
 | 
			
		||||
								<span v-if="g.isStarted && !g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
 | 
			
		||||
								<span v-else-if="!g.isEnded" :class="$style.gamePreviewStatusWaiting"><MkEllipsis/></span>
 | 
			
		||||
								<span v-else>{{ i18n.ts._reversi.ended }}</span>
 | 
			
		||||
								<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
 | 
			
		||||
							</div>
 | 
			
		||||
@@ -60,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
			<MkPagination :pagination="gamesPagination" :disableAutoLoad="true">
 | 
			
		||||
				<template #default="{ items }">
 | 
			
		||||
					<div :class="$style.gamePreviews">
 | 
			
		||||
						<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
 | 
			
		||||
						<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isStarted && !g.isEnded && $style.gamePreviewWaiting, g.isStarted && !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
 | 
			
		||||
							<div :class="$style.gamePreviewPlayers">
 | 
			
		||||
								<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
 | 
			
		||||
								<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
 | 
			
		||||
@@ -71,7 +72,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
								<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
 | 
			
		||||
							</div>
 | 
			
		||||
							<div :class="$style.gamePreviewFooter">
 | 
			
		||||
								<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
 | 
			
		||||
								<span v-if="g.isStarted && !g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
 | 
			
		||||
								<span v-else-if="!g.isEnded" :class="$style.gamePreviewStatusWaiting"><MkEllipsis/></span>
 | 
			
		||||
								<span v-else>{{ i18n.ts._reversi.ended }}</span>
 | 
			
		||||
								<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
 | 
			
		||||
							</div>
 | 
			
		||||
@@ -137,7 +139,9 @@ if ($i) {
 | 
			
		||||
	const connection = useStream().useChannel('reversi');
 | 
			
		||||
 | 
			
		||||
	connection.on('matched', x => {
 | 
			
		||||
		startGame(x.game);
 | 
			
		||||
		if (matchingUser.value != null || matchingAny.value) {
 | 
			
		||||
			startGame(x.game);
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	connection.on('invited', invitation => {
 | 
			
		||||
@@ -153,6 +157,7 @@ if ($i) {
 | 
			
		||||
const invitations = ref<Misskey.entities.UserLite[]>([]);
 | 
			
		||||
const matchingUser = ref<Misskey.entities.UserLite | null>(null);
 | 
			
		||||
const matchingAny = ref<boolean>(false);
 | 
			
		||||
const noIrregularRules = ref<boolean>(false);
 | 
			
		||||
 | 
			
		||||
function startGame(game: Misskey.entities.ReversiGameDetailed) {
 | 
			
		||||
	matchingUser.value = null;
 | 
			
		||||
@@ -178,6 +183,7 @@ async function matchHeatbeat() {
 | 
			
		||||
	} else if (matchingAny.value) {
 | 
			
		||||
		const res = await misskeyApi('reversi/match', {
 | 
			
		||||
			userId: null,
 | 
			
		||||
			noIrregularRules: noIrregularRules.value,
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (res != null) {
 | 
			
		||||
@@ -195,10 +201,22 @@ async function matchUser() {
 | 
			
		||||
	matchHeatbeat();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function matchAny() {
 | 
			
		||||
	matchingAny.value = true;
 | 
			
		||||
 | 
			
		||||
	matchHeatbeat();
 | 
			
		||||
function matchAny(ev: MouseEvent) {
 | 
			
		||||
	os.popupMenu([{
 | 
			
		||||
		text: i18n.ts._reversi.allowIrregularRules,
 | 
			
		||||
		action: () => {
 | 
			
		||||
			noIrregularRules.value = false;
 | 
			
		||||
			matchingAny.value = true;
 | 
			
		||||
			matchHeatbeat();
 | 
			
		||||
		},
 | 
			
		||||
	}, {
 | 
			
		||||
		text: i18n.ts._reversi.disallowIrregularRules,
 | 
			
		||||
		action: () => {
 | 
			
		||||
			noIrregularRules.value = true;
 | 
			
		||||
			matchingAny.value = true;
 | 
			
		||||
			matchHeatbeat();
 | 
			
		||||
		},
 | 
			
		||||
	}], ev.currentTarget ?? ev.target);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function cancelMatching() {
 | 
			
		||||
@@ -220,12 +238,14 @@ async function accept(user) {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
useInterval(matchHeatbeat, 1000 * 10, { immediate: false, afterMounted: true });
 | 
			
		||||
useInterval(matchHeatbeat, 1000 * 5, { immediate: false, afterMounted: true });
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	misskeyApi('reversi/invitations').then(_invitations => {
 | 
			
		||||
		invitations.value = _invitations;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	window.addEventListener('beforeunload', cancelMatching);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onDeactivated(() => {
 | 
			
		||||
@@ -273,6 +293,10 @@ definePageMetadata(computed(() => ({
 | 
			
		||||
	box-shadow: inset 0 0 8px 0px var(--accent);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gamePreviewWaiting {
 | 
			
		||||
	box-shadow: inset 0 0 8px 0px var(--warn);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gamePreviewPlayers {
 | 
			
		||||
	text-align: center;
 | 
			
		||||
	padding: 16px;
 | 
			
		||||
@@ -306,6 +330,12 @@ definePageMetadata(computed(() => ({
 | 
			
		||||
	animation: blink 2s infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gamePreviewStatusWaiting {
 | 
			
		||||
	color: var(--warn);
 | 
			
		||||
	font-weight: bold;
 | 
			
		||||
	animation: blink 2s infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.waitingScreen {
 | 
			
		||||
	text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,6 @@
 | 
			
		||||
 | 
			
		||||
import { onUnmounted, Ref, ref, watch } from 'vue';
 | 
			
		||||
import { BroadcastChannel } from 'broadcast-channel';
 | 
			
		||||
import defaultsDeep from 'lodash.defaultsdeep';
 | 
			
		||||
import { $i } from '@/account.js';
 | 
			
		||||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { get, set } from '@/scripts/idb-proxy.js';
 | 
			
		||||
@@ -81,14 +80,37 @@ export class Storage<T extends StateDef> {
 | 
			
		||||
		this.loaded = this.ready.then(() => this.load());
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private isPureObject(value: unknown): value is Record<string, unknown> {
 | 
			
		||||
		return value !== null && typeof value === 'object' && !Array.isArray(value);
 | 
			
		||||
	private isPureObject(value: unknown): value is Record<string | number | symbol, unknown> {
 | 
			
		||||
		return typeof value === 'object' && value !== null && !Array.isArray(value);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private mergeState<T>(value: T, def: T): T {
 | 
			
		||||
	/**
 | 
			
		||||
	 * valueにないキーをdefからもらう(再帰的)\
 | 
			
		||||
	 * nullはそのまま、undefinedはdefの値
 | 
			
		||||
	 **/
 | 
			
		||||
	private mergeObject<X>(value: X, def: X): X {
 | 
			
		||||
		if (this.isPureObject(value) && this.isPureObject(def)) {
 | 
			
		||||
			if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def);
 | 
			
		||||
			return defaultsDeep(value, def) as T;
 | 
			
		||||
			const result = structuredClone(value) as X;
 | 
			
		||||
			for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
 | 
			
		||||
				if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
 | 
			
		||||
					result[k] = v;
 | 
			
		||||
				} else if (this.isPureObject(v) && this.isPureObject(result[k])) {
 | 
			
		||||
					const child = structuredClone(result[k]) as X[keyof X] & Record<string | number | symbol, unknown>;
 | 
			
		||||
					result[k] = this.mergeObject<typeof v>(child, v);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			return result;
 | 
			
		||||
		}
 | 
			
		||||
		return value;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private mergeState<X>(value: X, def: X): X {
 | 
			
		||||
		if (this.isPureObject(value) && this.isPureObject(def)) {
 | 
			
		||||
			const merged = this.mergeObject(value, def);
 | 
			
		||||
 | 
			
		||||
			if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);
 | 
			
		||||
 | 
			
		||||
			return merged as X;
 | 
			
		||||
		}
 | 
			
		||||
		return value;
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,7 @@
 | 
			
		||||
	"scripts": {
 | 
			
		||||
		"build": "node ./build.js",
 | 
			
		||||
		"build:tsc": "npm run tsc",
 | 
			
		||||
		"tsc": "npm run ts-esm && npm run ts-dts",
 | 
			
		||||
		"tsc": "npm run tsc-esm && npm run tsc-dts",
 | 
			
		||||
		"tsc-esm": "tsc --outDir built/esm",
 | 
			
		||||
		"tsc-dts": "tsc --outDir built/dts --declaration true --emitDeclarationOnly true --declarationMap true",
 | 
			
		||||
		"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build:tsc\"",
 | 
			
		||||
@@ -26,7 +26,7 @@
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@misskey-dev/eslint-plugin": "1.0.0",
 | 
			
		||||
		"@types/matter-js": "0.19.6",
 | 
			
		||||
		"@types/node": "20.11.5",
 | 
			
		||||
		"@types/node": "20.11.6",
 | 
			
		||||
		"@types/seedrandom": "3.0.8",
 | 
			
		||||
		"@typescript-eslint/eslint-plugin": "6.18.1",
 | 
			
		||||
		"@typescript-eslint/parser": "6.18.1",
 | 
			
		||||
@@ -38,7 +38,7 @@
 | 
			
		||||
		"built"
 | 
			
		||||
	],
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"esbuild": "0.19.11",
 | 
			
		||||
		"esbuild": "0.19.12",
 | 
			
		||||
		"eventemitter3": "5.0.1",
 | 
			
		||||
		"glob": "^10.3.10",
 | 
			
		||||
		"matter-js": "0.19.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@misskey-dev/eslint-plugin": "^1.0.0",
 | 
			
		||||
		"@readme/openapi-parser": "2.5.0",
 | 
			
		||||
		"@types/node": "20.11.5",
 | 
			
		||||
		"@types/node": "20.11.6",
 | 
			
		||||
		"@typescript-eslint/eslint-plugin": "6.18.1",
 | 
			
		||||
		"@typescript-eslint/parser": "6.18.1",
 | 
			
		||||
		"eslint": "8.56.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -35,11 +35,11 @@
 | 
			
		||||
		"url": "git+https://github.com/misskey-dev/misskey.js.git"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@microsoft/api-extractor": "7.39.1",
 | 
			
		||||
		"@microsoft/api-extractor": "7.39.3",
 | 
			
		||||
		"@misskey-dev/eslint-plugin": "1.0.0",
 | 
			
		||||
		"@swc/jest": "0.2.31",
 | 
			
		||||
		"@types/jest": "29.5.11",
 | 
			
		||||
		"@types/node": "20.11.5",
 | 
			
		||||
		"@types/node": "20.11.6",
 | 
			
		||||
		"@typescript-eslint/eslint-plugin": "6.18.1",
 | 
			
		||||
		"@typescript-eslint/parser": "6.18.1",
 | 
			
		||||
		"eslint": "8.56.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -4597,6 +4597,7 @@ export type components = {
 | 
			
		||||
      timeoutUserId: string | null;
 | 
			
		||||
      black: number | null;
 | 
			
		||||
      bw: string;
 | 
			
		||||
      noIrregularRules: boolean;
 | 
			
		||||
      isLlotheo: boolean;
 | 
			
		||||
      canPutEverywhere: boolean;
 | 
			
		||||
      loopedBoard: boolean;
 | 
			
		||||
@@ -4632,6 +4633,7 @@ export type components = {
 | 
			
		||||
      timeoutUserId: string | null;
 | 
			
		||||
      black: number | null;
 | 
			
		||||
      bw: string;
 | 
			
		||||
      noIrregularRules: boolean;
 | 
			
		||||
      isLlotheo: boolean;
 | 
			
		||||
      canPutEverywhere: boolean;
 | 
			
		||||
      loopedBoard: boolean;
 | 
			
		||||
@@ -26298,6 +26300,10 @@ export type operations = {
 | 
			
		||||
        'application/json': {
 | 
			
		||||
          /** Format: misskey:id */
 | 
			
		||||
          userId?: string | null;
 | 
			
		||||
          /** @default false */
 | 
			
		||||
          noIrregularRules?: boolean;
 | 
			
		||||
          /** @default false */
 | 
			
		||||
          multiple?: boolean;
 | 
			
		||||
        };
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,7 @@
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@misskey-dev/eslint-plugin": "1.0.0",
 | 
			
		||||
		"@types/node": "20.11.5",
 | 
			
		||||
		"@types/node": "20.11.6",
 | 
			
		||||
		"@typescript-eslint/eslint-plugin": "6.18.1",
 | 
			
		||||
		"@typescript-eslint/parser": "6.18.1",
 | 
			
		||||
		"eslint": "8.56.0",
 | 
			
		||||
@@ -34,7 +34,7 @@
 | 
			
		||||
	},
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"crc-32": "1.2.2",
 | 
			
		||||
		"esbuild": "0.19.11",
 | 
			
		||||
		"esbuild": "0.19.12",
 | 
			
		||||
		"glob": "10.3.10"
 | 
			
		||||
	},
 | 
			
		||||
	"files": [
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
		"lint": "pnpm typecheck && pnpm eslint"
 | 
			
		||||
	},
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"esbuild": "0.19.11",
 | 
			
		||||
		"esbuild": "0.19.12",
 | 
			
		||||
		"idb-keyval": "6.2.1",
 | 
			
		||||
		"misskey-js": "workspace:*"
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										573
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										573
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user