diff --git a/.github/workflows/check-cherrypick-js-autogen.yml b/.github/workflows/check-cherrypick-js-autogen.yml index fa3f85150b..4752c64736 100644 --- a/.github/workflows/check-cherrypick-js-autogen.yml +++ b/.github/workflows/check-cherrypick-js-autogen.yml @@ -1,7 +1,7 @@ name: Check CherryPick JS autogen on: - pull_request: + pull_request_target: branches: - master - develop @@ -15,13 +15,14 @@ jobs: pull-requests: write env: - api_json_names: "api-base.json api-head.json" + api_json_name: "api-head.json" steps: - name: checkout uses: actions/checkout@v4 with: submodules: true + ref: ${{ github.event.pull_request.head.sha }} - name: setup pnpm uses: pnpm/action-setup@v2 @@ -87,22 +88,27 @@ jobs: find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d . ';' ls -la + - name: get head checksum + run: |- + checksum=$(realpath head_checksum) + + cd packages/cherrypick-js/src + find autogen -type f -exec sh -c 'echo $(sed -E "s/^\s+\*\s+generatedAt:.+$//" {} | sha256sum | cut -d" " -f 1) {}' \; > $checksum + cd ../../.. + - name: build autogen run: |- - for name in $(echo $api_json_names) - do - checksum=$(mktemp) - mv $name packages/cherrypick-js/generator/api.json + checksum=$(realpath ${api_json_name}_checksum) + mv $api_json_name packages/cherrypick-js/generator/api.json cd packages/cherrypick-js/generator pnpm run generate - find built -type f -exec sh -c 'echo $(sed -E "s/^\s+\*\s+generatedAt:.+$//" {} | sha256sum | cut -d" " -f 1) {}' \; > $checksum - cd ../../.. - cp $checksum ${name}_checksum - done + cd built + find autogen -type f -exec sh -c 'echo $(sed -E "s/^\s+\*\s+generatedAt:.+$//" {} | sha256sum | cut -d" " -f 1) {}' \; > $checksum + cd ../../../.. - name: check update for type definitions - run: diff $(echo -n ${api_json_names} | awk -v RS=" " '{ printf "%s_checksum ", $0 }') + run: diff head_checksum ${api_json_name}_checksum - name: send message if: failure() @@ -125,3 +131,4 @@ jobs: comment_tag: check-cherrypick-js-autogen mode: delete message: "Thank you!" + create_if_not_exists: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7fdd980396..2193cb2ae2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -92,6 +92,6 @@ jobs: - run: pnpm i --frozen-lockfile - run: pnpm --filter cherrypick-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 ${{ matrix.workspace }} run typecheck diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml new file mode 100644 index 0000000000..657213c5e5 --- /dev/null +++ b/.github/workflows/on-release-created.yml @@ -0,0 +1,45 @@ +name: On Release Created (Publish cherrypick-js) + +on: + release: + types: [created] + + workflow_dispatch: + +jobs: + publish-cherrypick-js: + name: Publish cherrypick-js + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + strategy: + matrix: + node-version: [20.10.0] + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + - name: Publish package + run: | + corepack enable + pnpm i --frozen-lockfile + pnpm build + pnpm --filter cherrypick-js publish --access public --no-git-checks --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + NPM_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/.github/workflows/test-cherrypick-js.yml b/.github/workflows/test-cherrypick-js.yml index 84b40c5769..347c0dc987 100644 --- a/.github/workflows/test-cherrypick-js.yml +++ b/.github/workflows/test-cherrypick-js.yml @@ -54,3 +54,17 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/cherrypick-js/coverage/coverage-final.json + + check-version: + # ルートの package.json と packages/cherrypick-js/package.json のバージョンが一致しているかを確認する + name: Check version + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + - name: Check version + run: | + if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/cherrypick-js/package.json)" ]; then + echo "Version mismatch!" + exit 1 + fi diff --git a/locales/index.d.ts b/locales/index.d.ts index 0dd498a6c4..beace85fbf 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -10837,6 +10837,18 @@ export interface Locale extends ILocale { * 対局がキャンセルされました */ "gameCanceled": string; + /** + * 開始時に対局をタイムラインに投稿 + */ + "shareToTlTheGameWhenStart": string; + /** + * 対局を開始しました! #MisskeyReversi + */ + "iStartedAGame": string; + /** + * 相手が設定を変更しました + */ + "opponentHasSettingsChanged": string; }; "_offlineScreen": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 713fb93aa8..55e0fd944a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2883,6 +2883,9 @@ _reversi: freeMatch: "フリーマッチ" lookingForPlayer: "対戦相手を探しています" gameCanceled: "対局がキャンセルされました" + shareToTlTheGameWhenStart: "開始時に対局をタイムラインに投稿" + iStartedAGame: "対局を開始しました! #MisskeyReversi" + opponentHasSettingsChanged: "相手が設定を変更しました" _offlineScreen: title: "オフライン - サーバーに接続できません" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 5547a9f897..68b6921231 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -380,8 +380,11 @@ hcaptcha: "hCaptcha(キャプチャ)" enableHcaptcha: "hCaptcha(キャプチャ)をつけとく" hcaptchaSiteKey: "サイトキー" hcaptchaSecretKey: "シークレットキー" +mcaptcha: "mCaptcha" +enableMcaptcha: "hCaptcha(キャプチャ)をつけとく" mcaptchaSiteKey: "サイトキー" mcaptchaSecretKey: "シークレットキー" +mcaptchaInstanceUrl: "mCaptchaのインスタンスのURL" recaptcha: "reCAPTCHA" enableRecaptcha: "reCAPTCHA(リキャプチャ)を有効にする" recaptchaSiteKey: "サイトキー" @@ -641,6 +644,7 @@ medium: "中" small: "小" generateAccessToken: "アクセストークンの発行" permission: "権限" +adminPermission: "管理者権限" enableAll: "全部使えるようにする" disableAll: "全部使えへんようにする" tokenRequested: "アカウントへのアクセス許してやったらどうや" @@ -1069,6 +1073,8 @@ limitWidthOfReaction: "ツッコミの最大横幅を制限して、ちっさく noteIdOrUrl: "ノートIDかURL" video: "動画" videos: "動画" +audio: "音声" +audioFiles: "音声" dataSaver: "データケチケチ" accountMigration: "アカウントのお引っ越し" accountMoved: "このユーザーはさらのアカウントに引っ越したで:" @@ -1201,7 +1207,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: "オンにしたらこのお知らせができた時点でおる人らにだけお知らせが行くで。切ったらこの知らせが行ったあとにアカウント作った人にもちゃんとお知らせが行くで。" @@ -1575,6 +1599,13 @@ _achievements: _tutorialCompleted: title: "CherryPickひよっこ講座 修了証" description: "チュートリアル全部やった" + _bubbleGameExplodingHead: + title: "🤯" + description: "バブルゲームで最も大きいモノを出した" + _bubbleGameDoubleExplodingHead: + title: "ダブル🤯" + description: "バブルゲームで最も大きいモノを2つ同時に出した" + flavor: "これくらいの おべんとばこに 🤯 🤯 ちょっとつめて" _role: new: "ロールの作成" edit: "ロールの編集" @@ -2499,6 +2530,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 0dc9683748..5172af5c75 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -461,9 +461,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "hCaptcha 활성화" hcaptchaSiteKey: "사이트 키" hcaptchaSecretKey: "시크릿 키" +mcaptcha: "mCaptcha" enableMcaptcha: "mCaptcha 활성화" mcaptchaSiteKey: "사이트 키" mcaptchaSecretKey: "시크릿 키" +mcaptchaInstanceUrl: "mCaptcha 인스턴스 URL" recaptcha: "reCAPTCHA" enableRecaptcha: "reCAPTCHA 활성화" recaptchaSiteKey: "사이트 키" @@ -730,6 +732,7 @@ medium: "보통" small: "작게" generateAccessToken: "액세스 토큰 생성" permission: "권한" +adminPermission: "관리자 권한" enableAll: "전체 선택" disableAll: "전체 해제" tokenRequested: "계정 접근 허용" @@ -773,6 +776,7 @@ useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용돼요. other: "기타" regenerateLoginToken: "로그인 토큰 재생성" regenerateLoginTokenDescription: "이 작업은 로그인할 때 사용되는 내부 토큰을 다시 생성해요. 일반적으로 이 작업을 실행할 필요는 없지만, 다른 사람이 계정에 대한 접근 권한을 가지고 있다고 생각되거나, 공공장소에서 접속한 후 로그아웃하지 않았을 때 사용할 수 있어요. 이 기능을 사용하면 이 계정으로 로그인한 모든 기기에서 로그아웃 되고, 다시 로그인 해야해요." +theKeywordWhenSearchingForCustomEmoji: "커스텀 이모지를 검색할 때 키워드로 설정돼요." setMultipleBySeparatingWithSpace: "공백으로 구분해서 여러 개 설정할 수 있어요." fileIdOrUrl: "파일 ID 또는 URL" behavior: "동작" @@ -1161,6 +1165,8 @@ limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시" noteIdOrUrl: "노트 ID 및 URL" video: "동영상" videos: "동영상" +audio: "오디오" +audioFiles: "오디오 파일" dataSaver: "데이터 절약 모드" accountMigration: "계정 이동" accountMoved: "이 사용자는 다음 계정으로 이사했어요:" @@ -1299,6 +1305,7 @@ seasonalScreenEffect: "계절에 따른 화면 연출" decorate: "장식하기" addMfmFunction: "장식 추가" enableQuickAddMfmFunction: "고급 MFM 선택기 표시하기" +bubbleGame: "버블 게임" sfx: "효과음" soundWillBePlayed: "사운드가 재생돼요" showReplay: "리플레이 보기" @@ -1306,7 +1313,7 @@ replay: "리플레이" replaying: "리플레이 중" ranking: "랭킹" lastNDays: "최근 {n}일" -backToTitle: "타이틀로" +backToTitle: "타이틀로 가기" hemisphere: "거주 지역" withSensitive: "민감한 파일이 포함된 노트 표시" userSaysSomethingSensitive: "{name}님의 민감한 파일이 포함된 게시물" @@ -1335,6 +1342,10 @@ _messaging: direct: "다이렉트 메시지" _bubbleGame: howToPlay: "플레이 방법" + _howToPlay: + section1: "위치를 조정하여 상자에 물건을 떨어뜨려요." + section2: "같은 종류의 물건이 붙으면 다른 물건으로 바뀌면서 점수를 얻을 수 있어요." + section3: "상자에서 물건이 넘치면 게임 오버예요. 상자에서 물건이 넘치지 않도록 조심하면서 물건을 융합해 높은 점수를 획득하세요!" _announcement: forExistingUsers: "기존 사용자에게만 알리기" forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 사용자에게만 표시해요. 비활성화하면 게시 후에 가입한 사용자에게도 표시해요." @@ -1789,6 +1800,13 @@ _achievements: _tutorialCompleted: title: "CherryPick 입문자 과정 수료증" description: "튜토리얼을 완료했어요. 어서오세요!" + _bubbleGameExplodingHead: + title: "🤯" + description: "버블 게임에서 가장 큰 물건을 내놓았어요" + _bubbleGameDoubleExplodingHead: + title: "더블🤯" + description: "버블게임에서 가장 큰 물건 2개를 동시에 내놓았어요" + flavor: "이 정도만 도시락통에 🤯🤯 조금만 더" _role: new: "새 역할 생성" edit: "역할 편집" @@ -2741,7 +2759,7 @@ _dataSaver: description: "URL 미리보기의 썸네일 이미지를 불러오지 않아요." _code: title: "코드 문법 강조" - description: "MFM 등에서 코드 문법 강조 기법을 사용할 때, 탭하기 전까지는 불러오지 않아요. 코드 문법 강조 기능은 강조할 언어마다 해당 정의 파일을 불러와야 하지만, 이를 자동으로 불러오지 않게 되어 데이터 사용량을 줄일 수 있어요." + description: "MFM 등에서 코드 문법 강조 기법을 사용할 때, 클릭하기 전까지는 불러오지 않아요. 코드 문법 강조 기능은 강조할 언어마다 해당 정의 파일을 불러와야 하지만, 이를 자동으로 불러오지 않게 되어 데이터 사용량을 줄일 수 있어요." _hemisphere: N: "북반구" S: "남반구" @@ -2751,7 +2769,7 @@ _reversi: gameSettings: "대국 설정" chooseBoard: "보드 선택" blackOrWhite: "선공/후공" - blackIs: "{name}님이 흑(선행)" + blackIs: "{name}님이 흑(선공)" rules: "규칙" thisGameIsStartedSoon: "대국이 곧 시작돼요. 준비해 주세요" waitingForOther: "상대방의 준비가 완료되기를 기다리고 있어요" @@ -2763,9 +2781,9 @@ _reversi: myTurn: "내 차례에요" turnOf: "{name}님의 차례에요" pastTurnOf: "{name}님의 차례" - surrender: "투료" - surrendered: "투료에 의해" - timeout: "마감 시간" + surrender: "기권(투료)" + surrendered: "기권(투료)에 의해" + timeout: "시간 초과" drawn: "무승부" won: "{name}님의 승리" black: "흑" @@ -2776,7 +2794,7 @@ _reversi: allGames: "모두의 대국" ended: "종료" playing: "대국 중" - isLlotheo: "돌이 적은 쪽이 이김(오델로)" + isLlotheo: "돌이 적은 사람이 승리(로세오)" loopedMap: "반복 맵" canPutEverywhere: "어디에나 둘 수 있는 모드" timeLimitForEachTurn: "1턴의 시간 제한" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 26254fccc4..ad973adb39 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -2563,7 +2563,7 @@ _reversi: turnCount: "{count} 回合" myGames: "我的對弈" allGames: "所有對弈" - ended: "" + ended: "已結束" playing: "正在對弈" isLlotheo: "子較少的一方為勝(顛倒規則)" loopedMap: "循環棋盤" diff --git a/package.json b/package.json index a3fa4f354c..b5a2c44577 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cherrypick", "version": "4.6.0", - "basedMisskeyVersion": "2024.2.0-beta.3", + "basedMisskeyVersion": "2024.2.0-beta.6", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/package.json b/packages/backend/package.json index ae4212d4fb..979b2767e6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -113,7 +113,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": "4.25.2", diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index e847f82b32..cccf18aef5 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 { @@ -90,14 +89,30 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise { + public async matchSpecificUser(me: MiUser, targetUser: MiUser, multiple = false): Promise { 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'); @@ -107,23 +122,42 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { 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), - }); - - return null; } + //#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 { + public async matchAnyUser(me: MiUser, multiple = false): Promise { + 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'); @@ -139,15 +173,14 @@ 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); if (userIds.length > 0) { - // pick random - const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)]; + const matchedUserId = userIds[0]; await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId); @@ -155,7 +188,10 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { return game; } else { - await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id); + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.zadd('reversi:matchAny', Date.now(), me.id); + redisPipeline.expire('reversi:matchAny', 15, 'NX'); + await redisPipeline.exec(); return null; } } @@ -170,6 +206,14 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { await this.redisClient.zrem('reversi:matchAny', user.id); } + @bindThis + public async cleanOutdatedGames() { + await this.reversiGamesRepository.delete({ + id: LessThan(this.idService.gen(Date.now() - 1000 * 60 * 10)), + isStarted: false, + }); + } + @bindThis public async gameReady(gameId: MiReversiGame['id'], user: MiUser, ready: boolean) { const game = await this.get(gameId); @@ -255,7 +299,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 +326,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 +379,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { public async getInvitations(user: MiUser): Promise { 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 +450,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, @@ -536,7 +580,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/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index 0ffe160d52..64622e81fc 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 187f1e9b3e..405b03ffdc 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -404,6 +404,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'; @@ -805,6 +806,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: [ @@ -1211,6 +1213,7 @@ const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass $reversi_invitations, $reversi_showGame, $reversi_surrender, + $reversi_verify, ], exports: [ $admin_meta, @@ -1606,6 +1609,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 58c02c4165..fa8b14a81a 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -404,6 +404,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], @@ -803,6 +804,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 aed5d003b9..6decd25058 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 { // 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 { // 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 fde9d06565..33925eb9d1 100644 --- a/packages/backend/src/server/api/endpoints/reversi/match.ts +++ b/packages/backend/src/server/api/endpoints/reversi/match.ts @@ -37,6 +37,7 @@ export const paramDef = { type: 'object', properties: { userId: { type: 'string', format: 'misskey:id', nullable: true }, + multiple: { type: 'boolean', default: false }, }, required: [], } as const; @@ -56,7 +57,7 @@ export default class extends Endpoint { // 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, 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..c5069bf290 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/verify.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick 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 { // 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 558e1e171a..f75eb6ff82 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; } } @@ -75,14 +73,6 @@ class ReversiGameChannel extends Channel { this.reversiService.putStoneToGame(this.gameId!, this.user, pos, id); } - @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 { 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 { public create(id: string, connection: Channel['connection']): ReversiGameChannel { return new ReversiGameChannel( this.reversiService, - this.reversiGamesRepository, this.reversiGameEntityService, id, connection, diff --git a/packages/cherrypick-js/LICENSE b/packages/cherrypick-js/LICENSE index 11c1f9ce22..e24ce71ce9 100644 --- a/packages/cherrypick-js/LICENSE +++ b/packages/cherrypick-js/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2022 syuilo and other contributors +Copyright (c) 2021-2024 syuilo and noridev and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index bddb8c7087..e15677e6d0 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -1729,6 +1729,8 @@ declare namespace entities { ReversiShowGameRequest, ReversiShowGameResponse, ReversiSurrenderRequest, + ReversiVerifyRequest, + ReversiVerifyResponse, Error_2 as Error, UserLite, UserDetailedNotMeOnly, @@ -2790,6 +2792,12 @@ type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200 // @public (undocumented) 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']; diff --git a/packages/cherrypick-js/package.json b/packages/cherrypick-js/package.json index d39df6fca8..1edc1c581d 100644 --- a/packages/cherrypick-js/package.json +++ b/packages/cherrypick-js/package.json @@ -1,7 +1,8 @@ { "type": "module", "name": "cherrypick-js", - "version": "0.0.16-cherrypick.1", + "version": "4.6.0", + "basedMisskeyVersion": "2024.2.0-beta.3", "description": "CherryPick SDK for JavaScript", "types": "./built/dts/index.d.ts", "exports": { diff --git a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts index 2ac2ca7540..07885fbb3b 100644 --- a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts +++ b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts @@ -1,7 +1,7 @@ /* * version: 4.6.0 - * basedMisskeyVersion: 2024.2.0-beta.3 - * generatedAt: 2024-01-22T11:38:57.200Z + * basedMisskeyVersion: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:33:32.527Z */ import type { SwitchCaseResponseType } from '../api.js'; @@ -4416,5 +4416,16 @@ declare module '../api.js' { params: P, credential?: string | null, ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; } } diff --git a/packages/cherrypick-js/src/autogen/endpoint.ts b/packages/cherrypick-js/src/autogen/endpoint.ts index 31c296a226..e91544eff7 100644 --- a/packages/cherrypick-js/src/autogen/endpoint.ts +++ b/packages/cherrypick-js/src/autogen/endpoint.ts @@ -1,7 +1,7 @@ /* * version: 4.6.0 - * basedMisskeyVersion: 2024.2.0-beta.3 - * generatedAt: 2024-01-22T11:38:57.197Z + * basedMisskeyVersion: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:33:32.525Z */ import type { @@ -599,6 +599,8 @@ import type { ReversiShowGameRequest, ReversiShowGameResponse, ReversiSurrenderRequest, + ReversiVerifyRequest, + ReversiVerifyResponse, } from './entities.js'; export type Endpoints = { @@ -999,4 +1001,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/cherrypick-js/src/autogen/entities.ts b/packages/cherrypick-js/src/autogen/entities.ts index 595a71cf2e..fa863e0991 100644 --- a/packages/cherrypick-js/src/autogen/entities.ts +++ b/packages/cherrypick-js/src/autogen/entities.ts @@ -1,7 +1,7 @@ /* * version: 4.6.0 - * basedMisskeyVersion: 2024.2.0-beta.3 - * generatedAt: 2024-01-22T11:38:57.195Z + * basedMisskeyVersion: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:33:32.523Z */ import { operations } from './types.js'; @@ -601,3 +601,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/cherrypick-js/src/autogen/models.ts b/packages/cherrypick-js/src/autogen/models.ts index 5fef2c6bfc..8300e6d4af 100644 --- a/packages/cherrypick-js/src/autogen/models.ts +++ b/packages/cherrypick-js/src/autogen/models.ts @@ -1,7 +1,7 @@ /* * version: 4.6.0 - * basedMisskeyVersion: 2024.2.0-beta.3 - * generatedAt: 2024-01-22T11:38:57.194Z + * basedMisskeyVersion: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:33:32.522Z */ import { components } from './types.js'; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index 0f9729f96e..614274f49f 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -3,8 +3,8 @@ /* * version: 4.6.0 - * basedMisskeyVersion: 2024.2.0-beta.3 - * generatedAt: 2024-01-22T11:38:57.091Z + * basedMisskeyVersion: 2024.2.0-beta.6 + * generatedAt: 2024-01-24T07:33:32.436Z */ /** @@ -3807,6 +3807,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; @@ -27982,6 +27991,8 @@ export type operations = { 'application/json': { /** Format: misskey:id */ userId?: string | null; + /** @default false */ + multiple?: boolean; }; }; }; @@ -28176,5 +28187,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/frontend/assets/reversi/logo.png b/packages/frontend/assets/reversi/logo.png index 4b0d58dec1..724a311ea1 100644 Binary files a/packages/frontend/assets/reversi/logo.png and b/packages/frontend/assets/reversi/logo.png differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 1b0696bcf5..4d9f76b55b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -45,10 +45,8 @@ "cherrypick-mfm-js": "0.24.0-cherrypick.4", "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 a0ce8b5ad3..fa63d13fdd 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