diff options
| author | Marie <marie@kaifa.ch> | 2024-01-25 14:21:42 +0100 |
|---|---|---|
| committer | Marie <marie@kaifa.ch> | 2024-01-25 14:21:42 +0100 |
| commit | 913dd581efa3e0afe6da57131d213965a376f90e (patch) | |
| tree | 7d6241b02e299d86c02db9305c23a5142390e044 | |
| parent | fix: notes/versions not checking visibility (diff) | |
| parent | 2024.2.0-beta.7 (diff) | |
| download | sharkey-913dd581efa3e0afe6da57131d213965a376f90e.tar.gz sharkey-913dd581efa3e0afe6da57131d213965a376f90e.tar.bz2 sharkey-913dd581efa3e0afe6da57131d213965a376f90e.zip | |
merge: upstream
41 files changed, 816 insertions, 200 deletions
diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml index edbfa980d2..8e69713c33 100644 --- a/.forgejo/workflows/lint.yml +++ b/.forgejo/workflows/lint.yml @@ -88,8 +88,8 @@ jobs: - run: pnpm i --frozen-lockfile - run: pnpm --filter misskey-js run build if: ${{ matrix.workspace == 'backend' }} - - run: pnpm --filter misskey-reversi run build + - run: pnpm --filter misskey-reversi run build:tsc if: ${{ matrix.workspace == 'backend' }} - - run: pnpm --filter misskey-bubble-game run build + - run: pnpm --filter misskey-bubble-game run build:tsc if: ${{ matrix.workspace == 'backend' }} - run: pnpm --filter ${{ matrix.workspace }} run typecheck diff --git a/locales/index.d.ts b/locales/index.d.ts index 87c50a5ea8..138c87b765 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9846,6 +9846,26 @@ export interface Locale extends ILocale { * 対局がキャンセルされました */ "gameCanceled": string; + /** + * 開始時に対局をタイムラインに投稿 + */ + "shareToTlTheGameWhenStart": string; + /** + * 対局を開始しました! #MisskeyReversi + */ + "iStartedAGame": string; + /** + * 相手が設定を変更しました + */ + "opponentHasSettingsChanged": string; + /** + * 変則許可 (完全フリー) + */ + "allowIrregularRules": string; + /** + * 変則なし + */ + "disallowIrregularRules": string; }; "_offlineScreen": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 7fe65f423d..a154acca68 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2622,6 +2622,11 @@ _reversi: freeMatch: "フリーマッチ" lookingForPlayer: "対戦相手を探しています" gameCanceled: "対局がキャンセルされました" + shareToTlTheGameWhenStart: "開始時に対局をタイムラインに投稿" + iStartedAGame: "対局を開始しました! #MisskeyReversi" + opponentHasSettingsChanged: "相手が設定を変更しました" + allowIrregularRules: "変則許可 (完全フリー)" + disallowIrregularRules: "変則なし" _offlineScreen: title: "オフライン - サーバーに接続できません" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 649d69fba2..4b32ff359e 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -382,8 +382,11 @@ hcaptcha: "hCaptcha(キャプチャ)" enableHcaptcha: "hCaptcha(キャプチャ)をつけとく" hcaptchaSiteKey: "サイトキー" hcaptchaSecretKey: "シークレットキー" +mcaptcha: "mCaptcha" +enableMcaptcha: "hCaptcha(キャプチャ)をつけとく" mcaptchaSiteKey: "サイトキー" mcaptchaSecretKey: "シークレットキー" +mcaptchaInstanceUrl: "mCaptchaのインスタンスのURL" recaptcha: "reCAPTCHA" enableRecaptcha: "reCAPTCHA(リキャプチャ)を有効にする" recaptchaSiteKey: "サイトキー" @@ -631,6 +634,7 @@ medium: "中" small: "小" generateAccessToken: "アクセストークンの発行" permission: "権限" +adminPermission: "管理者権限" enableAll: "全部使えるようにする" disableAll: "全部使えへんようにする" tokenRequested: "アカウントへのアクセス許してやったらどうや" @@ -1057,6 +1061,8 @@ limitWidthOfReaction: "ツッコミの最大横幅を制限して、ちっさく noteIdOrUrl: "ノートIDかURL" video: "動画" videos: "動画" +audio: "音声" +audioFiles: "音声" dataSaver: "データケチケチ" accountMigration: "アカウントのお引っ越し" accountMoved: "このユーザーはさらのアカウントに引っ越したで:" @@ -1189,7 +1195,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: "オンにしたらこのお知らせができた時点でおる人らにだけお知らせが行くで。切ったらこの知らせが行ったあとにアカウント作った人にもちゃんとお知らせが行くで。" @@ -1560,6 +1584,13 @@ _achievements: _tutorialCompleted: title: "Sharkeyひよっこ講座 修了証" description: "チュートリアル全部やった" + _bubbleGameExplodingHead: + title: "🤯" + description: "バブルゲームで最も大きいモノを出した" + _bubbleGameDoubleExplodingHead: + title: "ダブル🤯" + description: "バブルゲームで最も大きいモノを2つ同時に出した" + flavor: "これくらいの おべんとばこに 🤯 🤯 ちょっとつめて" _role: new: "ロールの作成" edit: "ロールの編集" @@ -2412,6 +2443,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: "サーバーに接続できへんわ" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 5ac398475b..de4cb3395b 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -380,9 +380,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "hCaptcha 활성화" hcaptchaSiteKey: "사이트 키" hcaptchaSecretKey: "시크릿 키" +mcaptcha: "mCaptcha" enableMcaptcha: "mCaptcha 활성화" mcaptchaSiteKey: "사이트 키" mcaptchaSecretKey: "시크릿 키" +mcaptchaInstanceUrl: "mCaptcha 인스턴스 URL" recaptcha: "reCAPTCHA" enableRecaptcha: "reCAPTCHA 활성화" recaptchaSiteKey: "사이트 키" @@ -630,6 +632,7 @@ medium: "보통" small: "작게" generateAccessToken: "액세스 토큰 생성" permission: "권한" +adminPermission: "관리자 권한" enableAll: "전체 선택" disableAll: "전체 해제" tokenRequested: "계정 접근 허용" @@ -673,6 +676,7 @@ useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용됩니 other: "기타" regenerateLoginToken: "로그인 토큰을 재생성" regenerateLoginTokenDescription: "로그인할 때 사용되는 내부 토큰을 재생성합니다. 일반적으로 이 작업을 실행할 필요는 없습니다. 이 기능을 사용하면 이 계정으로 로그인한 모든 기기에서 로그아웃됩니다." +theKeywordWhenSearchingForCustomEmoji: "맞춤 이모티콘을 검색할 때 키워드가 됩니다." setMultipleBySeparatingWithSpace: "공백으로 구분하여 여러 개 설정할 수 있습니다." fileIdOrUrl: "파일 ID 또는 URL" behavior: "동작" @@ -1055,6 +1059,8 @@ limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하 noteIdOrUrl: "노트 ID 및 URL" video: "동영상" videos: "동영상" +audio: "소리" +audioFiles: "소리" dataSaver: "데이터 절약 모드" accountMigration: "계정 이동" accountMoved: "이 사용자는 다음 계정으로 이사했습니다:" @@ -1187,10 +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: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시합니다. 비활성화하면 게시 후에 가입한 유저에게도 표시합니다." @@ -1561,6 +1582,13 @@ _achievements: _tutorialCompleted: title: "Misskey 입문자 과정 수료증" description: "튜토리얼을 완료했습니다" + _bubbleGameExplodingHead: + title: "🤯" + description: "버블 게임에서 가장 큰 물건을 내놓았다" + _bubbleGameDoubleExplodingHead: + title: "더블 🤯" + description: "버블게임에서 가장 큰 물건 2개를 동시에 내놓았다." + flavor: "이 정도만 도시락통에 🤯 🤯 조금만 더" _role: new: "새 역할 생성" edit: "역할 수정" @@ -2413,6 +2441,48 @@ _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: "대국이 취소되었습니다" +_offlineScreen: + title: "오프라인 - 서버에 접속할 수 없습니다" + header: "서버에 접속할 수 없습니다" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 5e730202c9..59f2ed6d2d 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -2473,7 +2473,7 @@ _reversi: turnCount: "{count} 回合" myGames: "我的對弈" allGames: "所有對弈" - ended: "" + ended: "已結束" playing: "正在對弈" isLlotheo: "子較少的一方為勝(顛倒規則)" loopedMap: "循環棋盤" @@ -2482,6 +2482,11 @@ _reversi: freeMatch: "自由對戰" lookingForPlayer: "正在搜尋對手" gameCanceled: "對弈已被取消" + shareToTlTheGameWhenStart: "在遊戲開始時將對弈資訊發布到時間軸" + iStartedAGame: "對弈開始了! #MisskeyReversi" + opponentHasSettingsChanged: "對手更改了設定" + allowIrregularRules: "允許異常規則(完全自由)" + disallowIrregularRules: "不允許異常規則" _offlineScreen: title: "離線-無法連接伺服器" header: "無法連接伺服器" diff --git a/package.json b/package.json index a54aea6372..487a963558 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sharkey", - "version": "2024.2.0-beta1", + "version": "2024.2.0-beta2", "codename": "shonk", "repository": { "type": "git", diff --git a/packages/backend/migration/1706081514499-reversi-6.js b/packages/backend/migration/1706081514499-reversi-6.js new file mode 100644 index 0000000000..de870be446 --- /dev/null +++ b/packages/backend/migration/1706081514499-reversi-6.js @@ -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"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 4ca4f85c5e..839deaf909 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -107,7 +107,6 @@ "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-multer": "^2.0.3", diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 4f68fabc4c..50bddb0c2b 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -5,10 +5,9 @@ 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 { IsNull, LessThan, MoreThan } from 'typeorm'; import type { MiReversiGame, ReversiGamesRepository, @@ -25,7 +24,7 @@ import { Serialized } from '@/types.js'; import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; -const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec +const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec @Injectable() export class ReversiService implements OnApplicationShutdown, OnModuleInit { @@ -86,44 +85,82 @@ export class ReversiService implements OnApplicationShutdown, 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); + const game = await this.matched(targetUser.id, me.id, { + noIrregularRules: false, + }); return game; - } else { - this.redisClient.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id); + } + //#endregion - this.globalEventService.publishReversiStream(targetUser.id, 'invited', { - user: await this.userEntityService.pack(me, targetUser), - }); + 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(); - return null; - } + 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'); @@ -131,7 +168,9 @@ export class ReversiService implements OnApplicationShutdown, 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; } @@ -139,23 +178,35 @@ export class ReversiService implements OnApplicationShutdown, 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; } } @@ -167,7 +218,15 @@ export class ReversiService implements OnApplicationShutdown, 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 @@ -221,7 +280,7 @@ export class ReversiService implements OnApplicationShutdown, 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, @@ -234,6 +293,7 @@ export class ReversiService implements OnApplicationShutdown, 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'], @@ -255,7 +315,13 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { bw = parseInt(game.bw, 10); } - const crc32 = CRC32.str(JSON.stringify(game.logs)).toString(); + const engine = new Reversi.Game(game.map, { + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard, + }); + + const crc32 = engine.calcCrc32().toString(); const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() .set({ @@ -276,12 +342,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { this.cacheGame(updatedGame); //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 - const engine = new Reversi.Game(updatedGame.map, { - isLlotheo: updatedGame.isLlotheo, - canPutEverywhere: updatedGame.canPutEverywhere, - loopedBoard: updatedGame.loopedBoard, - }); - if (engine.isEnded) { let winnerId; if (engine.winner === true) { @@ -335,7 +395,7 @@ export class ReversiService implements OnApplicationShutdown, 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; @@ -406,7 +466,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { const serializeLogs = Reversi.Serializer.serializeLogs(logs); - const crc32 = CRC32.str(JSON.stringify(serializeLogs)).toString(); + const crc32 = engine.calcCrc32().toString(); const updatedGame = { ...game, @@ -538,7 +598,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { if (game == null) throw new Error('game not found'); if (crc32.toString() !== game.crc32) { - return await this.reversiGameEntityService.packDetail(game); + return game; } else { return null; } diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index 6c89a70599..1a689a7b53 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -61,6 +61,7 @@ export class ReversiGameEntityService { canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, timeLimitForEachTurn: game.timeLimitForEachTurn, + noIrregularRules: game.noIrregularRules, logs: game.logs, map: game.map, }); @@ -105,6 +106,7 @@ export class ReversiGameEntityService { canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, timeLimitForEachTurn: game.timeLimitForEachTurn, + noIrregularRules: game.noIrregularRules, }); } diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts index 11d236e458..c03335dd63 100644 --- a/packages/backend/src/models/ReversiGame.ts +++ b/packages/backend/src/models/ReversiGame.ts @@ -109,6 +109,11 @@ export class MiReversiGame { @Column('boolean', { default: false, }) + public noIrregularRules: boolean; + + @Column('boolean', { + default: false, + }) public isLlotheo: boolean; @Column('boolean', { diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts index f8a5e7451c..ff4c78eeb0 100644 --- a/packages/backend/src/models/json-schema/reversi-game.ts +++ b/packages/backend/src/models/json-schema/reversi-game.ts @@ -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, diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index e252c5d8a1..17b6c8ba0c 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -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.'); } } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5b4ba9ddcd..35aceeaa66 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -385,6 +385,7 @@ 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 * as ep___reversi_verify from './endpoints/reversi/verify.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import type { Provider } from '@nestjs/common'; @@ -768,6 +769,7 @@ const $reversi_match: Provider = { provide: 'ep:reversi/match', useClass: ep___r 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 }; +const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep___reversi_verify.default }; @Module({ imports: [ @@ -1155,6 +1157,7 @@ const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass $reversi_invitations, $reversi_showGame, $reversi_surrender, + $reversi_verify, ], exports: [ $admin_meta, @@ -1533,6 +1536,7 @@ const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass $reversi_invitations, $reversi_showGame, $reversi_surrender, + $reversi_verify, ], }) export class EndpointsModule {} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b5d8c6d678..8de5171475 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -386,6 +386,7 @@ 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 * as ep___reversi_verify from './endpoints/reversi/verify.js'; const eps = [ ['admin/meta', ep___admin_meta], @@ -767,6 +768,7 @@ const eps = [ ['reversi/invitations', ep___reversi_invitations], ['reversi/show-game', ep___reversi_showGame], ['reversi/surrender', ep___reversi_surrender], + ['reversi/verify', ep___reversi_verify], ]; interface IEndpointMetaBase { diff --git a/packages/backend/src/server/api/endpoints/reversi/games.ts b/packages/backend/src/server/api/endpoints/reversi/games.ts index f28fe5d987..c1b2ff1702 100644 --- a/packages/backend/src/server/api/endpoints/reversi/games.ts +++ b/packages/backend/src/server/api/endpoints/reversi/games.ts @@ -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(); diff --git a/packages/backend/src/server/api/endpoints/reversi/match.ts b/packages/backend/src/server/api/endpoints/reversi/match.ts index 1065ce5a89..f8dee21c4c 100644 --- a/packages/backend/src/server/api/endpoints/reversi/match.ts +++ b/packages/backend/src/server/api/endpoints/reversi/match.ts @@ -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; diff --git a/packages/backend/src/server/api/endpoints/reversi/verify.ts b/packages/backend/src/server/api/endpoints/reversi/verify.ts new file mode 100644 index 0000000000..5f5af6ce67 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/verify.ts @@ -0,0 +1,64 @@ +/* + * 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 = { + errors: { + noSuchGame: { + message: 'No such game.', + code: 'NO_SUCH_GAME', + id: '8fb05624-b525-43dd-90f7-511852bdfeee', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + desynced: { type: 'boolean' }, + game: { + type: 'object', + optional: true, nullable: true, + ref: 'ReversiGameDetailed', + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + gameId: { type: 'string', format: 'misskey:id' }, + crc32: { type: 'string' }, + }, + required: ['gameId', 'crc32'], +} 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.checkCrc(ps.gameId, ps.crc32); + if (game) { + return { + desynced: true, + game: await this.reversiGameEntityService.packDetail(game), + }; + } else { + return { + desynced: false, + }; + } + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 820c80006b..fb24a29b75 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { MiReversiGame, ReversiGamesRepository } from '@/models/_.js'; +import type { MiReversiGame } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { ReversiService } from '@/core/ReversiService.js'; @@ -19,7 +19,6 @@ class ReversiGameChannel extends Channel { constructor( private reversiService: ReversiService, - private reversiGamesRepository: ReversiGamesRepository, private reversiGameEntityService: ReversiGameEntityService, id: string, @@ -42,7 +41,6 @@ class ReversiGameChannel extends Channel { case 'updateSettings': this.updateSettings(body.key, body.value); break; case 'cancel': this.cancelGame(); break; case 'putStone': this.putStone(body.pos, body.id); break; - case 'resync': this.resync(body.crc32); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break; } } @@ -76,14 +74,6 @@ class ReversiGameChannel extends Channel { } @bindThis - private async resync(crc32: string | number) { - const game = await this.reversiService.checkCrc(this.gameId!, crc32); - if (game) { - this.send('resynced', game); - } - } - - @bindThis private async claimTimeIsUp() { if (this.user == null) return; @@ -104,9 +94,6 @@ export class ReversiGameChannelService implements MiChannelService<false> { public readonly kind = ReversiGameChannel.kind; constructor( - @Inject(DI.reversiGamesRepository) - private reversiGamesRepository: ReversiGamesRepository, - private reversiService: ReversiService, private reversiGameEntityService: ReversiGameEntityService, ) { @@ -116,7 +103,6 @@ export class ReversiGameChannelService implements MiChannelService<false> { public create(id: string, connection: Channel['connection']): ReversiGameChannel { return new ReversiGameChannel( this.reversiService, - this.reversiGamesRepository, this.reversiGameEntityService, id, connection, diff --git a/packages/frontend/assets/reversi/logo.png b/packages/frontend/assets/reversi/logo.png Binary files differindex 4b0d58dec1..724a311ea1 100644 --- a/packages/frontend/assets/reversi/logo.png +++ b/packages/frontend/assets/reversi/logo.png diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 1d25ff1e78..54c9b83298 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -42,10 +42,8 @@ "chartjs-plugin-zoom": "2.0.1", "chromatic": "10.3.1", "compare-versions": "6.1.0", - "crc-32": "^1.2.2", "cropperjs": "2.0.0-beta.4", "date-fns": "2.30.0", - "defu": "^6.1.4", "escape-regexp": "0.0.1", "estree-walker": "3.0.3", "eventemitter3": "5.0.1", diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue index 55bb4b13b0..67d32c505a 100644 --- a/packages/frontend/src/components/MkHorizontalSwipe.vue +++ b/packages/frontend/src/components/MkHorizontalSwipe.vue @@ -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); diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 107da09f9f..f67d177ac3 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -143,7 +143,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, onActivated, onDeactivated, 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'; @@ -240,11 +239,17 @@ watch(logPos, (v) => { if (game.value.isStarted && !game.value.isEnded) { useInterval(() => { - if (game.value.isEnded || props.connection == null) return; - const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString(); + if (game.value.isEnded) return; + const crc32 = engine.value.calcCrc32(); if (_DEV_) console.log('crc32', crc32); - props.connection.send('resync', { - crc32: crc32, + misskeyApi('reversi/verify', { + gameId: game.value.id, + crc32: crc32.toString(), + }).then((res) => { + if (res.desynced) { + console.log('resynced'); + restoreGame(res.game!); + } }); }, 10000, { immediate: false, afterMounted: true }); } @@ -392,12 +397,6 @@ function restoreGame(_game) { checkEnd(); } -function onStreamResynced(_game) { - console.log('resynced'); - - restoreGame(_game); -} - async function surrender() { const { canceled } = await os.confirm({ type: 'warning', @@ -450,7 +449,6 @@ function share() { onMounted(() => { if (props.connection != null) { props.connection.on('log', onStreamLog); - props.connection.on('resynced', onStreamResynced); props.connection.on('ended', onStreamEnded); } }); @@ -458,7 +456,6 @@ onMounted(() => { onActivated(() => { if (props.connection != null) { props.connection.on('log', onStreamLog); - props.connection.on('resynced', onStreamResynced); props.connection.on('ended', onStreamEnded); } }); @@ -466,7 +463,6 @@ onActivated(() => { onDeactivated(() => { if (props.connection != null) { props.connection.off('log', onStreamLog); - props.connection.off('resynced', onStreamResynced); props.connection.off('ended', onStreamEnded); } }); @@ -474,7 +470,6 @@ onDeactivated(() => { onUnmounted(() => { if (props.connection != null) { props.connection.off('log', onStreamLog); - props.connection.off('resynced', onStreamResynced); props.connection.off('ended', onStreamEnded); } }); diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 01f5510b4e..f64c98d0f3 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -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="ph-dice-five ph-bold ph-lg"></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' ? 'ph-circle-half ph-bold ph-lg' : 'ph-circle ph-bold ph-lg'"></i> + <div style="padding: 16px;"> + <div v-if="game.map == null"><i class="ph-dice-five ph-bold ph-lg"></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' ? 'ph-circle-half ph-bold ph-lg' : 'ph-circle ph-bold ph-lg'"></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) { diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index 7e918d01db..ea96609532 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -5,7 +5,7 @@ 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> @@ -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: 'ph-game-controller ph-bold ph-lg', diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue index 12d8709031..d1b784f633 100644 --- a/packages/frontend/src/pages/reversi/index.vue +++ b/packages/frontend/src/pages/reversi/index.vue @@ -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; } diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index b3d2374899..68c36ca1b4 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -7,7 +7,6 @@ import { onUnmounted, Ref, ref, watch } from 'vue'; import { BroadcastChannel } from 'broadcast-channel'; -import { defu } from 'defu'; 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> { + 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)) { + 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)) { - if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def); - return defu(value, def) as T; + const merged = this.mergeObject(value, def); + + if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); + + return merged as X; } return value; } diff --git a/packages/misskey-bubble-game/build.js b/packages/misskey-bubble-game/build.js new file mode 100644 index 0000000000..4744dfaf7b --- /dev/null +++ b/packages/misskey-bubble-game/build.js @@ -0,0 +1,31 @@ +import { build } from "esbuild"; +import { globSync } from "glob"; + +const entryPoints = globSync("./src/**/**.{ts,tsx}"); + +/** @type {import('esbuild').BuildOptions} */ +const options = { + entryPoints, + minify: true, + outdir: "./built/esm", + target: "es2022", + platform: "browser", + format: "esm", +}; + +if (process.env.WATCH === "true") { + options.watch = { + onRebuild(error, result) { + if (error) { + console.error("watch build failed:", error); + } else { + console.log("watch build succeeded:", result); + } + }, + }; +} + +build(options).catch((err) => { + process.stderr.write(err.stderr); + process.exit(1); +}); diff --git a/packages/misskey-bubble-game/package.json b/packages/misskey-bubble-game/package.json index 1dfa7afcdd..5a6b952e07 100644 --- a/packages/misskey-bubble-game/package.json +++ b/packages/misskey-bubble-game/package.json @@ -13,18 +13,21 @@ } }, "scripts": { - "build": "npm run ts", - "ts": "npm run ts-esm && npm run ts-dts", - "ts-esm": "tsc --outDir built/esm", - "ts-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\"", + "build": "node ./build.js", + "build:tsc": "npm run tsc", + "tsc": "npm run ts-esm && npm run ts-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\"", "eslint": "eslint . --ext .js,.jsx,.ts,.tsx", "typecheck": "tsc --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { "@misskey-dev/eslint-plugin": "1.0.0", + "@types/matter-js": "0.19.6", "@types/node": "20.11.5", + "@types/seedrandom": "3.0.8", "@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/parser": "6.18.1", "eslint": "8.56.0", @@ -35,9 +38,9 @@ "built" ], "dependencies": { - "@types/matter-js": "0.19.6", - "@types/seedrandom": "3.0.8", + "esbuild": "0.19.11", "eventemitter3": "5.0.1", + "glob": "^10.3.10", "matter-js": "0.19.0", "seedrandom": "3.0.5" } diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 2b95e01533..26f100e452 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1633,6 +1633,8 @@ declare namespace entities { ReversiShowGameRequest, ReversiShowGameResponse, ReversiSurrenderRequest, + ReversiVerifyRequest, + ReversiVerifyResponse, Error_2 as Error, UserLite, UserDetailedNotMeOnly, @@ -2645,6 +2647,12 @@ type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200 type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json']; // @public (undocumented) +type ReversiVerifyRequest = operations['reversi/verify']['requestBody']['content']['application/json']; + +// @public (undocumented) +type ReversiVerifyResponse = operations['reversi/verify']['responses']['200']['content']['application/json']; + +// @public (undocumented) type Role = components['schemas']['Role']; // @public (undocumented) diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 67abd0dabe..76c2c83b84 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1,6 +1,6 @@ /* - * version: 2024.2.0-beta.2 - * generatedAt: 2024-01-22T07:11:08.412Z + * version: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:32:10.455Z */ import type { SwitchCaseResponseType } from '../api.js'; @@ -4140,5 +4140,16 @@ declare module '../api.js' { params: P, credential?: string | null, ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * + * **Credential required**: *No* + */ + request<E extends 'reversi/verify', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; } } diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 21926a49f5..295b753401 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -1,6 +1,6 @@ /* - * version: 2024.2.0-beta.2 - * generatedAt: 2024-01-22T07:11:08.410Z + * version: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:32:10.453Z */ import type { @@ -569,6 +569,8 @@ import type { ReversiShowGameRequest, ReversiShowGameResponse, ReversiSurrenderRequest, + ReversiVerifyRequest, + ReversiVerifyResponse, } from './entities.js'; export type Endpoints = { @@ -951,4 +953,5 @@ export type Endpoints = { 'reversi/invitations': { req: EmptyRequest; res: ReversiInvitationsResponse }; 'reversi/show-game': { req: ReversiShowGameRequest; res: ReversiShowGameResponse }; 'reversi/surrender': { req: ReversiSurrenderRequest; res: EmptyResponse }; + 'reversi/verify': { req: ReversiVerifyRequest; res: ReversiVerifyResponse }; } diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 5aa100f69a..ffe6fe79f8 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -1,6 +1,6 @@ /* - * version: 2024.2.0-beta.2 - * generatedAt: 2024-01-22T07:11:08.408Z + * version: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:32:10.452Z */ import { operations } from './types.js'; @@ -571,3 +571,5 @@ export type ReversiInvitationsResponse = operations['reversi/invitations']['resp 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']; +export type ReversiVerifyRequest = operations['reversi/verify']['requestBody']['content']['application/json']; +export type ReversiVerifyResponse = operations['reversi/verify']['responses']['200']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 2402fd53ae..b7dcbfd951 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -1,6 +1,6 @@ /* - * version: 2024.2.0-beta.2 - * generatedAt: 2024-01-22T07:11:08.408Z + * version: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:32:10.450Z */ import { components } from './types.js'; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index b5a28aa519..ebdef3dc74 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -2,8 +2,8 @@ /* eslint @typescript-eslint/no-explicit-any: 0 */ /* - * version: 2024.2.0-beta.2 - * generatedAt: 2024-01-22T07:11:08.327Z + * version: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:32:10.370Z */ /** @@ -3645,6 +3645,15 @@ export type paths = { */ post: operations['reversi/surrender']; }; + '/reversi/verify': { + /** + * reversi/verify + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['reversi/verify']; + }; }; export type webhooks = Record<string, never>; @@ -4617,6 +4626,7 @@ export type components = { timeoutUserId: string | null; black: number | null; bw: string; + noIrregularRules: boolean; isLlotheo: boolean; canPutEverywhere: boolean; loopedBoard: boolean; @@ -4652,6 +4662,7 @@ export type components = { timeoutUserId: string | null; black: number | null; bw: string; + noIrregularRules: boolean; isLlotheo: boolean; canPutEverywhere: boolean; loopedBoard: boolean; @@ -26677,6 +26688,10 @@ export type operations = { 'application/json': { /** Format: misskey:id */ userId?: string | null; + /** @default false */ + noIrregularRules?: boolean; + /** @default false */ + multiple?: boolean; }; }; }; @@ -26871,5 +26886,63 @@ export type operations = { }; }; }; + /** + * reversi/verify + * @description No description provided. + * + * **Credential required**: *No* + */ + 'reversi/verify': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + gameId: string; + crc32: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + desynced: boolean; + game?: components['schemas']['ReversiGameDetailed'] | null; + }; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; }; diff --git a/packages/misskey-reversi/.eslintrc.cjs b/packages/misskey-reversi/.eslintrc.cjs index e2e31e9e33..db37a01098 100644 --- a/packages/misskey-reversi/.eslintrc.cjs +++ b/packages/misskey-reversi/.eslintrc.cjs @@ -1,4 +1,5 @@ module.exports = { + root: true, parserOptions: { tsconfigRootDir: __dirname, project: ['./tsconfig.json'], diff --git a/packages/misskey-reversi/build.js b/packages/misskey-reversi/build.js new file mode 100644 index 0000000000..4744dfaf7b --- /dev/null +++ b/packages/misskey-reversi/build.js @@ -0,0 +1,31 @@ +import { build } from "esbuild"; +import { globSync } from "glob"; + +const entryPoints = globSync("./src/**/**.{ts,tsx}"); + +/** @type {import('esbuild').BuildOptions} */ +const options = { + entryPoints, + minify: true, + outdir: "./built/esm", + target: "es2022", + platform: "browser", + format: "esm", +}; + +if (process.env.WATCH === "true") { + options.watch = { + onRebuild(error, result) { + if (error) { + console.error("watch build failed:", error); + } else { + console.log("watch build succeeded:", result); + } + }, + }; +} + +build(options).catch((err) => { + process.stderr.write(err.stderr); + process.exit(1); +}); diff --git a/packages/misskey-reversi/package.json b/packages/misskey-reversi/package.json index e65e484b62..bd8d4b498c 100644 --- a/packages/misskey-reversi/package.json +++ b/packages/misskey-reversi/package.json @@ -13,11 +13,12 @@ } }, "scripts": { - "build": "npm run ts", - "ts": "npm run ts-esm && npm run ts-dts", - "ts-esm": "tsc --outDir built/esm", - "ts-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\"", + "build": "node ./build.js", + "build:tsc": "npm run tsc", + "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\"", "eslint": "eslint . --ext .js,.jsx,.ts,.tsx", "typecheck": "tsc --noEmit", "lint": "pnpm typecheck && pnpm eslint" @@ -35,5 +36,8 @@ "built" ], "dependencies": { + "crc-32": "1.2.2", + "esbuild": "0.19.11", + "glob": "10.3.10" } } diff --git a/packages/misskey-reversi/src/game.ts b/packages/misskey-reversi/src/game.ts index f29b001447..caad17f9f2 100644 --- a/packages/misskey-reversi/src/game.ts +++ b/packages/misskey-reversi/src/game.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import CRC32 from 'crc-32'; + /** * true ... 黒 * false ... 白 @@ -204,6 +206,13 @@ export class Game { return ([] as number[]).concat(...diffVectors.map(effectsInLine)); } + public calcCrc32(): number { + return CRC32.str(JSON.stringify({ + board: this.board, + turn: this.turn, + })); + } + public get isEnded(): boolean { return this.turn === null; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8dab54703..5225660a1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,9 +187,6 @@ importers: content-disposition: specifier: 0.5.4 version: 0.5.4 - crc-32: - specifier: ^1.2.2 - version: 1.2.2 date-fns: specifier: 2.30.0 version: 2.30.0 @@ -750,18 +747,12 @@ importers: compare-versions: specifier: 6.1.0 version: 6.1.0 - crc-32: - specifier: ^1.2.2 - version: 1.2.2 cropperjs: specifier: 2.0.0-beta.4 version: 2.0.0-beta.4 date-fns: specifier: 2.30.0 version: 2.30.0 - defu: - specifier: ^6.1.4 - version: 6.1.4 escape-regexp: specifier: 0.0.1 version: 0.0.1 @@ -1130,15 +1121,15 @@ importers: packages/misskey-bubble-game: dependencies: - '@types/matter-js': - specifier: 0.19.6 - version: 0.19.6 - '@types/seedrandom': - specifier: 3.0.8 - version: 3.0.8 + esbuild: + specifier: 0.19.11 + version: 0.19.11 eventemitter3: specifier: 5.0.1 version: 5.0.1 + glob: + specifier: ^10.3.10 + version: 10.3.10 matter-js: specifier: 0.19.0 version: 0.19.0 @@ -1149,9 +1140,15 @@ importers: '@misskey-dev/eslint-plugin': specifier: 1.0.0 version: 1.0.0(@typescript-eslint/eslint-plugin@6.18.1)(@typescript-eslint/parser@6.18.1)(eslint-plugin-import@2.29.1)(eslint@8.56.0) + '@types/matter-js': + specifier: 0.19.6 + version: 0.19.6 '@types/node': specifier: 20.11.5 version: 20.11.5 + '@types/seedrandom': + specifier: 3.0.8 + version: 3.0.8 '@typescript-eslint/eslint-plugin': specifier: 6.18.1 version: 6.18.1(@typescript-eslint/parser@6.18.1)(eslint@8.56.0)(typescript@5.3.3) @@ -1269,6 +1266,16 @@ importers: version: 5.3.3 packages/misskey-reversi: + dependencies: + crc-32: + specifier: 1.2.2 + version: 1.2.2 + esbuild: + specifier: 0.19.11 + version: 0.19.11 + glob: + specifier: 10.3.10 + version: 10.3.10 devDependencies: '@misskey-dev/eslint-plugin': specifier: 1.0.0 @@ -7839,6 +7846,7 @@ packages: /@types/matter-js@0.19.6: resolution: {integrity: sha512-ffk6tqJM5scla+ThXmnox+mdfCo3qYk6yMjQsNcrbo6eQ5DqorVdtnaL+1agCoYzxUjmHeiNB7poBMAmhuLY7w==} + dev: true /@types/mdx@2.0.3: resolution: {integrity: sha512-IgHxcT3RC8LzFLhKwP3gbMPeaK7BM9eBH46OdapPA7yvuIUJ8H6zHZV53J8hGZcTSnt95jANt+rTBNUUc22ACQ==} @@ -8020,7 +8028,7 @@ packages: /@types/seedrandom@3.0.8: resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==} - dev: false + dev: true /@types/semver@7.5.6: resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} @@ -10926,6 +10934,7 @@ packages: /defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + dev: true /del@6.1.1: resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} |