diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4c8b97e785..83f83a67c6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,6 +13,7 @@ on: - packages/sw/** - packages/misskey-js/** - packages/misskey-bubble-game/** + - packages/misskey-mahjong/** - packages/misskey-reversi/** - packages/shared/eslint.config.js - .github/workflows/lint.yml @@ -25,6 +26,7 @@ on: - packages/sw/** - packages/misskey-js/** - packages/misskey-bubble-game/** + - packages/misskey-mahjong/** - packages/misskey-reversi/** - packages/shared/eslint.config.js - .github/workflows/lint.yml @@ -58,6 +60,7 @@ jobs: - sw - misskey-js - misskey-bubble-game + - misskey-mahjong - misskey-reversi env: eslint-cache-version: v1 @@ -106,6 +109,6 @@ jobs: - run: pnpm i --frozen-lockfile - run: pnpm --filter misskey-js run build if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }} - - run: pnpm --filter misskey-reversi run build + - run: pnpm --filter misskey-mahjong --filter misskey-reversi run build if: ${{ matrix.workspace == 'backend' }} - run: pnpm --filter ${{ matrix.workspace }} run typecheck diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 037e6dd7f1..d6cf02efdc 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -50,7 +50,7 @@ jobs: - name: Check pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml - name: Build dependent packages - run: pnpm -F misskey-js -F misskey-bubble-game -F misskey-reversi build + run: pnpm -F misskey-js -F misskey-bubble-game -F misskey-mahjong -F misskey-reversi build - name: Build storybook run: pnpm --filter frontend build-storybook - name: Publish to Chromatic diff --git a/Dockerfile b/Dockerfile index 9d5596f1f1..26204873bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"] +COPY --link ["packages/misskey-mahjong/package.json", "./packages/misskey-mahjong/"] ARG NODE_ENV=production @@ -56,6 +57,7 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"] +COPY --link ["packages/misskey-mahjong/package.json", "./packages/misskey-mahjong/"] ARG NODE_ENV=production @@ -92,10 +94,12 @@ COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/nod COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-reversi/node_modules ./packages/misskey-reversi/node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-bubble-game/node_modules ./packages/misskey-bubble-game/node_modules +COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-mahjong/node_modules ./packages/misskey-mahjong/node_modules COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/built ./packages/misskey-js/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-mahjong/built ./packages/misskey-mahjong/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ diff --git a/locales/index.d.ts b/locales/index.d.ts index 97505a1605..acb2f380ae 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11006,6 +11006,288 @@ export interface Locale extends ILocale { */ "useAvatarAsStone": string; }; + "_mahjong": { + /** + * 麻雀 + */ + "mahjong": string; + /** + * ルームに参加 + */ + "joinRoom": string; + /** + * ルームを作成 + */ + "createRoom": string; + /** + * 準備完了 + */ + "ready": string; + /** + * 準備を再開 + */ + "cancelReady": string; + /** + * 退室 + */ + "leave": string; + /** + * CPUを追加 + */ + "addCpu": string; + /** + * 東 + */ + "east": string; + /** + * 南 + */ + "south": string; + /** + * 西 + */ + "west": string; + /** + * 北 + */ + "north": string; + /** + * ドラ + */ + "dora": string; + /** + * 赤ドラ + */ + "redDora": string; + /** + * 飜 + */ + "fan": string; + "_fanNames": { + /** + * 満貫 + */ + "mangan": string; + /** + * 跳満 + */ + "haneman": string; + /** + * 倍満 + */ + "baiman": string; + /** + * 三倍満 + */ + "sanbaiman": string; + /** + * 役満 + */ + "yakuman": string; + /** + * 数え役満 + */ + "kazoeyakuman": string; + }; + "_yakus": { + /** + * 立直 + */ + "riichi": string; + /** + * 一発 + */ + "ippatsu": string; + /** + * 門前清自摸和 + */ + "tsumo": string; + /** + * 断么 + */ + "tanyao": string; + /** + * 平和 + */ + "pinfu": string; + /** + * 一盃口 + */ + "iipeko": string; + /** + * 東 + */ + "field-wind-e": string; + /** + * 南 + */ + "field-wind-s": string; + /** + * 東 + */ + "seat-wind-e": string; + /** + * 南 + */ + "seat-wind-s": string; + /** + * 西 + */ + "seat-wind-w": string; + /** + * 北 + */ + "seat-wind-n": string; + /** + * 白 + */ + "white": string; + /** + * 發 + */ + "green": string; + /** + * 中 + */ + "red": string; + /** + * 嶺上開花 + */ + "rinshan": string; + /** + * 搶槓 + */ + "chankan": string; + /** + * 海底摸月 + */ + "haitei": string; + /** + * 河底撈魚 + */ + "hotei": string; + /** + * 三色同順 + */ + "sanshoku-dojun": string; + /** + * 三色同刻 + */ + "sanshoku-doko": string; + /** + * 一気通貫 + */ + "ittsu": string; + /** + * 混全帯么九 + */ + "chanta": string; + /** + * 七対子 + */ + "chitoitsu": string; + /** + * 対々 + */ + "toitoi": string; + /** + * 三暗刻 + */ + "sananko": string; + /** + * 混老頭 + */ + "honroto": string; + /** + * 三槓子 + */ + "sankantsu": string; + /** + * 小三元 + */ + "shosangen": string; + /** + * ダブル立直 + */ + "double-riichi": string; + /** + * 混一色 + */ + "honitsu": string; + /** + * 清全帯么九 + */ + "junchan": string; + /** + * ニ盃口 + */ + "ryampeko": string; + /** + * 清一色 + */ + "chinitsu": string; + /** + * 国士無双 + */ + "kokushi": string; + /** + * 国士無双十三面待 + */ + "kokushi-13": string; + /** + * 四暗刻 + */ + "suanko": string; + /** + * 四暗刻単騎待 + */ + "suanko-tanki": string; + /** + * 大三元 + */ + "daisangen": string; + /** + * 字一色 + */ + "tsuiso": string; + /** + * 小四喜 + */ + "shosushi": string; + /** + * 大四喜 + */ + "daisushi": string; + /** + * 緑一色 + */ + "ryuiso": string; + /** + * 清老頭 + */ + "chinroto": string; + /** + * 四槓子 + */ + "sukantsu": string; + /** + * 九蓮宝燈 + */ + "churen": string; + /** + * 九連宝灯九面待 + */ + "churen-9": string; + /** + * 天和 + */ + "tenho": string; + /** + * 地和 + */ + "chiho": string; + }; + }; "_offlineScreen": { /** * オフライン - サーバーに接続できません diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 737e69a376..a006e821db 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2927,6 +2927,79 @@ _reversi: showBoardLabels: "盤面に行・列番号を表示" useAvatarAsStone: "石をアイコンにする" +_mahjong: + mahjong: "麻雀" + joinRoom: "ルームに参加" + createRoom: "ルームを作成" + ready: "準備完了" + cancelReady: "準備を再開" + leave: "退室" + addCpu: "CPUを追加" + east: "東" + south: "南" + west: "西" + north: "北" + dora: "ドラ" + redDora: "赤ドラ" + fan: "飜" + _fanNames: + mangan: "満貫" + haneman: "跳満" + baiman: "倍満" + sanbaiman: "三倍満" + yakuman: "役満" + kazoeyakuman: "数え役満" + _yakus: + "riichi": "立直" + "ippatsu": "一発" + "tsumo": "門前清自摸和" + "tanyao": "断么" + "pinfu": "平和" + "iipeko": "一盃口" + "field-wind-e": "東" + "field-wind-s": "南" + "seat-wind-e": "東" + "seat-wind-s": "南" + "seat-wind-w": "西" + "seat-wind-n": "北" + "white": "白" + "green": "發" + "red": "中" + "rinshan": "嶺上開花" + "chankan": "搶槓" + "haitei": "海底摸月" + "hotei": "河底撈魚" + "sanshoku-dojun": "三色同順" + "sanshoku-doko": "三色同刻" + "ittsu": "一気通貫" + "chanta": "混全帯么九" + "chitoitsu": "七対子" + "toitoi": "対々" + "sananko": "三暗刻" + "honroto": "混老頭" + "sankantsu": "三槓子" + "shosangen": "小三元" + "double-riichi": "ダブル立直" + "honitsu": "混一色" + "junchan": "清全帯么九" + "ryampeko": "ニ盃口" + "chinitsu": "清一色" + "kokushi": "国士無双" + "kokushi-13": "国士無双十三面待" + "suanko": "四暗刻" + "suanko-tanki": "四暗刻単騎待" + "daisangen": "大三元" + "tsuiso": "字一色" + "shosushi": "小四喜" + "daisushi": "大四喜" + "ryuiso": "緑一色" + "chinroto": "清老頭" + "sukantsu": "四槓子" + "churen": "九蓮宝燈" + "churen-9": "九連宝灯九面待" + "tenho": "天和" + "chiho": "地和" + _offlineScreen: title: "オフライン - サーバーに接続できません" header: "サーバーに接続できません" diff --git a/package.json b/package.json index 365fc3ed67..0afc3e858b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "packages/sw", "packages/misskey-js", "packages/misskey-reversi", - "packages/misskey-bubble-game" + "packages/misskey-bubble-game", + "packages/misskey-mahjong" ], "private": true, "scripts": { diff --git a/packages/backend/migration/1706234054207-mahjong.js b/packages/backend/migration/1706234054207-mahjong.js new file mode 100644 index 0000000000..2f78ebb7ce --- /dev/null +++ b/packages/backend/migration/1706234054207-mahjong.js @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Mahjong1706234054207 { + name = 'Mahjong1706234054207' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "mahjong_game" ("id" character varying(32) NOT NULL, "startedAt" TIMESTAMP WITH TIME ZONE, "endedAt" TIMESTAMP WITH TIME ZONE, "user1Id" character varying(32), "user2Id" character varying(32), "user3Id" character varying(32), "user4Id" character varying(32), "isEnded" boolean NOT NULL DEFAULT false, "winnerId" character varying(32), "timeLimitForEachTurn" smallint NOT NULL DEFAULT '90', "logs" jsonb NOT NULL DEFAULT '[]', CONSTRAINT "PK_77db54c0a9785d387e3fbbdd2f0" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_b98c78761a845b69e6540401264" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_f17b0ba519ae28f188a7915ad6f" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_64314ffd3cb59475b0d06330058" FOREIGN KEY ("user3Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_58a75f1ea2a810ae3986f72a0e3" FOREIGN KEY ("user4Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_58a75f1ea2a810ae3986f72a0e3"`); + await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_64314ffd3cb59475b0d06330058"`); + await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_f17b0ba519ae28f188a7915ad6f"`); + await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_b98c78761a845b69e6540401264"`); + await queryRunner.query(`DROP TABLE "mahjong_game"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 5ae856f67a..df35c5a929 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -139,6 +139,7 @@ "mime-types": "2.1.35", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", + "misskey-mahjong": "workspace:*", "ms": "3.0.0-canary.1", "nanoid": "5.1.5", "nested-property": "4.0.0", diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index d8617e343c..e395c0dcfc 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -77,6 +77,7 @@ import { ChannelFollowingService } from './ChannelFollowingService.js'; import { ChatService } from './ChatService.js'; import { RegistryApiService } from './RegistryApiService.js'; import { ReversiService } from './ReversiService.js'; +import { MahjongService } from './MahjongService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; import FederationChart from './chart/charts/federation.js'; @@ -224,6 +225,7 @@ const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; +const $MahjongService: Provider = { provide: 'MahjongService', useExisting: MahjongService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; @@ -374,6 +376,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChatService, RegistryApiService, ReversiService, + MahjongService, ChartLoggerService, FederationChart, @@ -520,6 +523,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChatService, $RegistryApiService, $ReversiService, + $MahjongService, $ChartLoggerService, $FederationChart, @@ -667,6 +671,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChatService, RegistryApiService, ReversiService, + MahjongService, FederationChart, NotesChart, @@ -811,6 +816,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChatService, $RegistryApiService, $ReversiService, + $MahjongService, $FederationChart, $NotesChart, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 3215b41c8d..e7b2185ded 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as Reversi from 'misskey-reversi'; +import * as Mmj from 'misskey-mahjong'; import type { MiChannel } from '@/models/Channel.js'; import type { MiUser } from '@/models/User.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; @@ -205,6 +206,78 @@ export interface ReversiGameEventTypes { userId: MiUser['id']; }; } + +export interface MahjongRoomEventTypes { + joined: { + index: number; + user: Packed<'UserLite'> | null; + }; + changeReadyStates: { + user1: boolean; + user2: boolean; + user3: boolean; + user4: boolean; + }; + started: { + room: Packed<'MahjongRoomDetailed'>; + }; + nextKyoku: { + room: Packed<'MahjongRoomDetailed'>; + }; + tsumo: { + house: Mmj.House; + tile: number; + }; + dahai: { + house: Mmj.House; + tile: number; + riichi: boolean; + }; + dahaiAndTsumo: { + dahaiHouse: Mmj.House; + dahaiTile: number; + tsumoTile: number; + riichi: boolean; + }; + ponned: { + caller: Mmj.House; + callee: Mmj.House; + tiles: readonly [number, number, number]; + }; + kanned: { + caller: Mmj.House; + callee: Mmj.House; + tiles: readonly [number, number, number, number]; + rinsyan: number; + }; + ciied: { + caller: Mmj.House; + callee: Mmj.House; + tiles: readonly [number, number, number]; + }; + ronned: { + callers: Mmj.House[]; + callee: Mmj.House; + handTiles: Record; + }; + ryuukyoku: object; + ankanned: { + house: Mmj.House; + tiles: readonly [number, number, number, number]; + rinsyan: number; + }; + kakanned: { + house: Mmj.House; + tiles: readonly [number, number, number, number]; + rinsyan: number; + from: Mmj.House; + }; + tsumoHora: { + house: Mmj.House; + handTiles: number[]; + tsumoTile: number; + }; +} //#endregion // 辞書(interface or type)から{ type, body }ユニオンを定義 @@ -322,6 +395,10 @@ export type GlobalEvents = { name: `reversiGameStream:${MiReversiGame['id']}`; payload: EventTypesToEventPayload; }; + mahjongRoom: { + name: `mahjongRoomStream:${string}`; + payload: EventUnionFromDictionary>; + }; }; // API event definitions @@ -431,4 +508,9 @@ export class GlobalEventService { public publishReversiGameStream(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void { this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value); } + + @bindThis + public publishMahjongRoomStream(roomId: string, type: K, value?: MahjongRoomEventTypes[K]): void { + this.publish(`mahjongRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value); + } } diff --git a/packages/backend/src/core/MahjongService.ts b/packages/backend/src/core/MahjongService.ts new file mode 100644 index 0000000000..19693b0fa0 --- /dev/null +++ b/packages/backend/src/core/MahjongService.ts @@ -0,0 +1,734 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { ModuleRef } from '@nestjs/core'; +import { IsNull, LessThan, MoreThan } from 'typeorm'; +import * as Mmj from 'misskey-mahjong'; +import type { + MiMahjongGame, + MahjongGamesRepository, +} from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { Serialized } from '@/types.js'; +import { Packed } from '@/misc/json-schema.js'; +import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js'; +import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; + +const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec +const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 10; // 10sec +const TURN_TIMEOUT_MS = 1000 * 30; // 30sec +const NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS = 1000 * 15; // 15sec + +type Room = { + id: string; + user1Id: MiUser['id']; + user2Id: MiUser['id'] | null; + user3Id: MiUser['id'] | null; + user4Id: MiUser['id'] | null; + user1: Packed<'UserLite'> | null; + user2: Packed<'UserLite'> | null; + user3: Packed<'UserLite'> | null; + user4: Packed<'UserLite'> | null; + user1Ai?: boolean; + user2Ai?: boolean; + user3Ai?: boolean; + user4Ai?: boolean; + user1Ready: boolean; + user2Ready: boolean; + user3Ready: boolean; + user4Ready: boolean; + user1Offline?: boolean; + user2Offline?: boolean; + user3Offline?: boolean; + user4Offline?: boolean; + isStarted?: boolean; + timeLimitForEachTurn: number; + + gameState?: Mmj.MasterState; +}; + +type CallingAnswers = { + pon: null | boolean; + cii: null | false | 'x__' | '_x_' | '__x'; + kan: null | boolean; + ron: { + e: null | boolean; + s: null | boolean; + w: null | boolean; + n: null | boolean; + }; +}; + +type NextKyokuConfirmation = { + user1: boolean; + user2: boolean; + user3: boolean; + user4: boolean; +}; + +function getUserIdOfHouse(room: Room, mj: Mmj.MasterGameEngine, house: Mmj.House): MiUser['id'] { + return mj.user1House === house ? room.user1Id : mj.user2House === house ? room.user2Id : mj.user3House === house ? room.user3Id : room.user4Id; +} + +function getHouseOfUserId(room: Room, mj: Mmj.MasterGameEngine, userId: MiUser['id']): Mmj.House { + return userId === room.user1Id ? mj.user1House : userId === room.user2Id ? mj.user2House : userId === room.user3Id ? mj.user3House : mj.user4House; +} + +@Injectable() +export class MahjongService implements OnApplicationShutdown, OnModuleInit { + private notificationService: NotificationService; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + + //@Inject(DI.mahjongGamesRepository) + //private mahjongGamesRepository: MahjongGamesRepository, + + private cacheService: CacheService, + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private reversiGameEntityService: ReversiGameEntityService, + private idService: IdService, + ) { + } + async onModuleInit() { + this.notificationService = this.moduleRef.get(NotificationService.name); + } + + @bindThis + private async saveRoom(room: Room) { + await this.redisClient.set(`mahjong:room:${room.id}`, JSON.stringify(room), 'EX', 60 * 30); + } + + @bindThis + public async createRoom(user: MiUser): Promise { + const room: Room = { + id: this.idService.gen(), + user1Id: user.id, + user2Id: null, + user3Id: null, + user4Id: null, + user1: await this.userEntityService.pack(user), + user1Ready: false, + user2Ready: false, + user3Ready: false, + user4Ready: false, + timeLimitForEachTurn: 30, + }; + await this.saveRoom(room); + return room; + } + + @bindThis + public async getRoom(id: Room['id']): Promise { + const room = await this.redisClient.get(`mahjong:room:${id}`); + if (!room) return null; + const parsed = JSON.parse(room); + return { + ...parsed, + }; + } + + @bindThis + public async joinRoom(roomId: Room['id'], user: MiUser): Promise { + const room = await this.getRoom(roomId); + if (!room) return null; + if (room.user1Id === user.id) return room; + if (room.user2Id === user.id) return room; + if (room.user3Id === user.id) return room; + if (room.user4Id === user.id) return room; + if (room.user2Id === null) { + room.user2Id = user.id; + room.user2 = await this.userEntityService.pack(user); + await this.saveRoom(room); + this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 2, user: room.user2 }); + return room; + } + if (room.user3Id === null) { + room.user3Id = user.id; + room.user3 = await this.userEntityService.pack(user); + await this.saveRoom(room); + this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 3, user: room.user3 }); + return room; + } + if (room.user4Id === null) { + room.user4Id = user.id; + room.user4 = await this.userEntityService.pack(user); + await this.saveRoom(room); + this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 4, user: room.user4 }); + return room; + } + + return null; + } + + @bindThis + public async addAi(roomId: Room['id'], user: MiUser): Promise { + const room = await this.getRoom(roomId); + if (!room) return null; + if (room.user1Id !== user.id) throw new Error('access denied'); + + if (room.user2Id == null && !room.user2Ai) { + room.user2Ai = true; + room.user2Ready = true; + await this.saveRoom(room); + this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 2, user: null }); + return room; + } + if (room.user3Id == null && !room.user3Ai) { + room.user3Ai = true; + room.user3Ready = true; + await this.saveRoom(room); + this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 3, user: null }); + return room; + } + if (room.user4Id == null && !room.user4Ai) { + room.user4Ai = true; + room.user4Ready = true; + await this.saveRoom(room); + this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 4, user: null }); + return room; + } + + return null; + } + + @bindThis + public async leaveRoom(roomId: Room['id'], user: MiUser): Promise { + const room = await this.getRoom(roomId); + if (!room) return null; + if (room.user1Id === user.id) { + room.user1Id = null; + room.user1 = null; + await this.saveRoom(room); + return room; + } + if (room.user2Id === user.id) { + room.user2Id = null; + room.user2 = null; + await this.saveRoom(room); + return room; + } + if (room.user3Id === user.id) { + room.user3Id = null; + room.user3 = null; + await this.saveRoom(room); + return room; + } + if (room.user4Id === user.id) { + room.user4Id = null; + room.user4 = null; + await this.saveRoom(room); + return room; + } + return null; + } + + @bindThis + public async changeReadyState(roomId: Room['id'], user: MiUser, ready: boolean): Promise { + const room = await this.getRoom(roomId); + if (!room) return; + + if (room.user1Id === user.id) { + room.user1Ready = ready; + await this.saveRoom(room); + } + if (room.user2Id === user.id) { + room.user2Ready = ready; + await this.saveRoom(room); + } + if (room.user3Id === user.id) { + room.user3Ready = ready; + await this.saveRoom(room); + } + if (room.user4Id === user.id) { + room.user4Ready = ready; + await this.saveRoom(room); + } + + this.globalEventService.publishMahjongRoomStream(room.id, 'changeReadyStates', { + user1: room.user1Ready, + user2: room.user2Ready, + user3: room.user3Ready, + user4: room.user4Ready, + }); + + if (room.user1Ready && room.user2Ready && room.user3Ready && room.user4Ready) { + await this.startGame(room); + } + } + + @bindThis + public async startGame(room: Room) { + if (!room.user1Ready || !room.user2Ready || !room.user3Ready || !room.user4Ready) { + throw new Error('Not ready'); + } + + room.gameState = Mmj.MasterGameEngine.createInitialState(); + room.isStarted = true; + await this.saveRoom(room); + + this.globalEventService.publishMahjongRoomStream(room.id, 'started', { room: room }); + + this.kyokuStarted(room); + } + + @bindThis + private kyokuStarted(room: Room) { + const mj = new Mmj.MasterGameEngine(room.gameState); + + this.waitForTurn(room, mj.turn, mj); + } + + @bindThis + private async answer(room: Room, mj: Mmj.MasterGameEngine, answers: CallingAnswers) { + const res = mj.commit_resolveCallingInterruption({ + pon: answers.pon ?? false, + cii: answers.cii ?? false, + kan: answers.kan ?? false, + ron: [...(answers.ron.e ? ['e'] : []), ...(answers.ron.s ? ['s'] : []), ...(answers.ron.w ? ['w'] : []), ...(answers.ron.n ? ['n'] : [])] as Mmj.House[], + }); + room.gameState = mj.getState(); + await this.saveRoom(room); + + switch (res.type) { + case 'tsumo': + this.globalEventService.publishMahjongRoomStream(room.id, 'tsumo', { house: res.house, tile: res.tile }); + this.waitForTurn(room, res.turn, mj); + break; + case 'ponned': + this.globalEventService.publishMahjongRoomStream(room.id, 'ponned', { caller: res.caller, callee: res.callee, tiles: res.tiles }); + this.waitForTurn(room, res.turn, mj); + break; + case 'kanned': + this.globalEventService.publishMahjongRoomStream(room.id, 'kanned', { caller: res.caller, callee: res.callee, tiles: res.tiles, rinsyan: res.rinsyan }); + this.waitForTurn(room, res.turn, mj); + break; + case 'ciied': + this.globalEventService.publishMahjongRoomStream(room.id, 'ciied', { caller: res.caller, callee: res.callee, tiles: res.tiles }); + this.waitForTurn(room, res.turn, mj); + break; + case 'ronned': + this.globalEventService.publishMahjongRoomStream(room.id, 'ronned', { + callers: res.callers, + callee: res.callee, + handTiles: { + e: mj.handTiles.e, + s: mj.handTiles.s, + w: mj.handTiles.w, + n: mj.handTiles.n, + }, + }); + this.endKyoku(room, mj); + break; + case 'ryuukyoku': + this.globalEventService.publishMahjongRoomStream(room.id, 'ryuukyoku', { + }); + this.endKyoku(room, mj); + break; + } + } + + @bindThis + private async endKyoku(room: Room, mj: Mmj.MasterGameEngine) { + const confirmation: NextKyokuConfirmation = { + user1: false, + user2: false, + user3: false, + user4: false, + }; + this.redisClient.set(`mahjong:gameNextKyokuConfirmation:${room.id}`, JSON.stringify(confirmation)); + const waitingStartedAt = Date.now(); + const interval = setInterval(async () => { + const confirmationRaw = await this.redisClient.get(`mahjong:gameNextKyokuConfirmation:${room.id}`); + if (confirmationRaw == null) { + clearInterval(interval); + return; + } + const confirmation = JSON.parse(confirmationRaw) as NextKyokuConfirmation; + const allConfirmed = confirmation.user1 && confirmation.user2 && confirmation.user3 && confirmation.user4; + if (allConfirmed || (Date.now() - waitingStartedAt > NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS)) { + await this.redisClient.del(`mahjong:gameNextKyokuConfirmation:${room.id}`); + clearInterval(interval); + this.nextKyoku(room, mj); + } + }, 2000); + } + + @bindThis + private async nextKyoku(room: Room, mj: Mmj.MasterGameEngine) { + const res = mj.commit_nextKyoku(); + room.gameState = mj.getState(); + await this.saveRoom(room); + this.globalEventService.publishMahjongRoomStream(room.id, 'nextKyoku', { + room: room, + }); + this.kyokuStarted(room); + } + + @bindThis + private async dahai(room: Room, mj: Mmj.MasterGameEngine, house: Mmj.House, tile: Mmj.TileId, riichi = false) { + const res = mj.commit_dahai(house, tile, riichi); + room.gameState = mj.getState(); + await this.saveRoom(room); + + const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => mj.getHouse(id)); + + if (res.ryuukyoku) { + this.endKyoku(room, mj); + this.globalEventService.publishMahjongRoomStream(room.id, 'ryuukyoku', { + }); + } else if (res.asking) { + const answers: CallingAnswers = { + pon: null, + cii: null, + kan: null, + ron: { + e: null, + s: null, + w: null, + n: null, + }, + }; + + // リーチ中はポン、チー、カンできない + if (res.canPonHouse != null && mj.riichis[res.canPonHouse]) { + answers.pon = false; + } + if (res.canCiiHouse != null && mj.riichis[res.canCiiHouse]) { + answers.cii = false; + } + if (res.canKanHouse != null && mj.riichis[res.canKanHouse]) { + answers.kan = false; + } + + if (aiHouses.includes(res.canPonHouse)) { + // TODO: ちゃんと思考するようにする + answers.pon = Math.random() < 0.25; + } + if (aiHouses.includes(res.canCiiHouse)) { + // TODO: ちゃんと思考するようにする + //answers.cii = Math.random() < 0.25; + answers.cii = false; + } + if (aiHouses.includes(res.canKanHouse)) { + // TODO: ちゃんと思考するようにする + answers.kan = Math.random() < 0.25; + } + for (const h of res.canRonHouses) { + if (aiHouses.includes(h)) { + // TODO: ちゃんと思考するようにする + } + } + + this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(answers)); + const waitingStartedAt = Date.now(); + const interval = setInterval(async () => { + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('arienai (gameCallingAsking)'); + const currentAnswers = JSON.parse(current) as CallingAnswers; + const allAnswered = !( + (res.canPonHouse != null && currentAnswers.pon == null) || + (res.canCiiHouse != null && currentAnswers.cii == null) || + (res.canKanHouse != null && currentAnswers.kan == null) || + (res.canRonHouses.includes('e') && currentAnswers.ron.e == null) || + (res.canRonHouses.includes('s') && currentAnswers.ron.s == null) || + (res.canRonHouses.includes('w') && currentAnswers.ron.w == null) || + (res.canRonHouses.includes('n') && currentAnswers.ron.n == null) + ); + if (allAnswered || (Date.now() - waitingStartedAt > CALL_AND_RON_ASKING_TIMEOUT_MS)) { + console.log(allAnswered ? 'ask all answerd' : 'ask timeout'); + await this.redisClient.del(`mahjong:gameCallingAsking:${room.id}`); + clearInterval(interval); + this.answer(room, mj, currentAnswers); + return; + } + }, 1000); + + this.globalEventService.publishMahjongRoomStream(room.id, 'dahai', { house: house, tile, riichi }); + } else { + this.globalEventService.publishMahjongRoomStream(room.id, 'dahaiAndTsumo', { dahaiHouse: house, dahaiTile: tile, tsumoTile: res.tsumoTile, riichi }); + + this.waitForTurn(room, res.next, mj); + } + } + + @bindThis + public async confirmNextKyoku(roomId: Room['id'], user: MiUser) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const confirmationRaw = await this.redisClient.get(`mahjong:gameNextKyokuConfirmation:${room.id}`); + if (confirmationRaw == null) return; + const confirmation = JSON.parse(confirmationRaw) as NextKyokuConfirmation; + if (user.id === room.user1Id) confirmation.user1 = true; + if (user.id === room.user2Id) confirmation.user2 = true; + if (user.id === room.user3Id) confirmation.user3 = true; + if (user.id === room.user4Id) confirmation.user4 = true; + await this.redisClient.set(`mahjong:gameNextKyokuConfirmation:${room.id}`, JSON.stringify(confirmation)); + } + + @bindThis + public async commit_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: Mmj.TileId, riichi = false) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + const mj = new Mmj.MasterGameEngine(room.gameState); + const myHouse = getHouseOfUserId(room, mj, user.id); + + await this.clearTurnWaitingTimer(room.id); + + await this.dahai(room, mj, myHouse, tile, riichi); + } + + @bindThis + public async commit_ankan(roomId: MiMahjongGame['id'], user: MiUser, tile: Mmj.TileId) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + const mj = new Mmj.MasterGameEngine(room.gameState); + const myHouse = getHouseOfUserId(room, mj, user.id); + + await this.clearTurnWaitingTimer(room.id); + + const res = mj.commit_ankan(myHouse, tile); + room.gameState = mj.getState(); + await this.saveRoom(room); + + this.globalEventService.publishMahjongRoomStream(room.id, 'ankanned', { house: myHouse, tiles: res.tiles, rinsyan: res.rinsyan }); + + this.waitForTurn(room, myHouse, mj); + } + + @bindThis + public async commit_kakan(roomId: MiMahjongGame['id'], user: MiUser, tile: Mmj.TileId) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + const mj = new Mmj.MasterGameEngine(room.gameState); + const myHouse = getHouseOfUserId(room, mj, user.id); + + await this.clearTurnWaitingTimer(room.id); + + const res = mj.commit_kakan(myHouse, tile); + room.gameState = mj.getState(); + await this.saveRoom(room); + + this.globalEventService.publishMahjongRoomStream(room.id, 'kakanned', { house: myHouse, tiles: res.tiles, rinsyan: res.rinsyan, from: res.from }); + } + + @bindThis + public async commit_tsumoHora(roomId: MiMahjongGame['id'], user: MiUser) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + const mj = new Mmj.MasterGameEngine(room.gameState); + const myHouse = getHouseOfUserId(room, mj, user.id); + + await this.clearTurnWaitingTimer(room.id); + + const res = mj.commit_tsumoHora(myHouse); + room.gameState = mj.getState(); + await this.saveRoom(room); + + this.globalEventService.publishMahjongRoomStream(room.id, 'tsumoHora', { house: myHouse, handTiles: res.handTiles, tsumoTile: res.tsumoTile }); + + this.endKyoku(room, mj); + } + + @bindThis + public async commit_ronHora(roomId: MiMahjongGame['id'], user: MiUser) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + const mj = new Mmj.MasterGameEngine(room.gameState); + const myHouse = getHouseOfUserId(room, mj, user.id); + + // TODO: 自分に回答する権利がある状態かバリデーション + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallingAnswers; + currentAnswers.ron[myHouse] = true; + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); + } + + @bindThis + public async commit_pon(roomId: MiMahjongGame['id'], user: MiUser) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + // TODO: 自分に回答する権利がある状態かバリデーション + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallingAnswers; + currentAnswers.pon = true; + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); + } + + @bindThis + public async commit_kan(roomId: MiMahjongGame['id'], user: MiUser) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + // TODO: 自分に回答する権利がある状態かバリデーション + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallingAnswers; + currentAnswers.kan = true; + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); + } + + @bindThis + public async commit_cii(roomId: MiMahjongGame['id'], user: MiUser, pattern: 'x__' | '_x_' | '__x') { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + // TODO: 自分に回答する権利がある状態かバリデーション + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallingAnswers; + currentAnswers.cii = pattern; + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); + } + + @bindThis + public async commit_nop(roomId: MiMahjongGame['id'], user: MiUser) { + const room = await this.getRoom(roomId); + if (room == null) return; + if (room.gameState == null) return; + + const mj = new Mmj.MasterGameEngine(room.gameState); + const myHouse = getHouseOfUserId(room, mj, user.id); + + // TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要 + const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`); + if (current == null) throw new Error('no asking found'); + const currentAnswers = JSON.parse(current) as CallingAnswers; + if (mj.askings.pon?.caller === myHouse) currentAnswers.pon = false; + if (mj.askings.cii?.caller === myHouse) currentAnswers.cii = false; + if (mj.askings.kan?.caller === myHouse) currentAnswers.kan = false; + if (mj.askings.ron != null && mj.askings.ron.callers.includes(myHouse)) currentAnswers.ron[myHouse] = false; + await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers)); + } + + /** + * プレイヤーの行動(打牌、加槓、暗槓、ツモ和了)を待つ + * 制限時間が過ぎたらツモ切り + * NOTE: 時間切れチェックが行われたときにタイミングによっては次のwaitingが始まっている場合があることを考慮し、Setに一意のIDを格納する構造としている + * @param room + * @param house + * @param mj + */ + @bindThis + private async waitForTurn(room: Room, house: Mmj.House, mj: Mmj.MasterGameEngine) { + const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => mj.getHouse(id)); + + if (mj.riichis[house]) { + // リーチ時はアガリ牌でない限りツモ切り + if (!Mmj.isAgarikei(mj.handTileTypes[house])) { + setTimeout(() => { + this.dahai(room, mj, house, mj.handTiles[house].at(-1)); + }, 500); + return; + } + } + + if (aiHouses.includes(house)) { + setTimeout(() => { + this.dahai(room, mj, house, mj.handTiles[house].at(-1)); + }, 500); + return; + } + + const id = Math.random().toString(36).slice(2); + console.log('waitForTurn', house, id); + this.redisClient.sadd(`mahjong:gameTurnWaiting:${room.id}`, id); + const waitingStartedAt = Date.now(); + const interval = setInterval(async () => { + const waiting = await this.redisClient.sismember(`mahjong:gameTurnWaiting:${room.id}`, id); + if (waiting === 0) { + clearInterval(interval); + return; + } + if (Date.now() - waitingStartedAt > TURN_TIMEOUT_MS) { + await this.redisClient.srem(`mahjong:gameTurnWaiting:${room.id}`, id); + console.log('turn timeout', house, id); + clearInterval(interval); + const handTiles = mj.handTiles[house]; + await this.dahai(room, mj, house, handTiles.at(-1)); + return; + } + }, 2000); + } + + /** + * プレイヤーが行動(打牌、加槓、暗槓、ツモ和了)したら呼ぶ + * @param roomId + */ + @bindThis + private async clearTurnWaitingTimer(roomId: Room['id']) { + await this.redisClient.del(`mahjong:gameTurnWaiting:${roomId}`); + } + + @bindThis + public packState(room: Room, me: MiUser) { + const mj = new Mmj.MasterGameEngine(room.gameState); + const myIndex = room.user1Id === me.id ? 1 : room.user2Id === me.id ? 2 : room.user3Id === me.id ? 3 : 4; + return mj.createPlayerState(myIndex); + } + + @bindThis + public async packRoom(room: Room, me: MiUser) { + if (room.gameState) { + return { + ...room, + gameState: this.packState(room, me), + }; + } else { + return { + ...room, + }; + } + } + + @bindThis + public dispose(): void { + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 77d2838e09..3d6b9bacc2 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -89,5 +89,6 @@ export const DI = { chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), + mahjongGamesRepository: Symbol('mahjongGamesRepository'), //#endregion }; diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 27aa3d89de..df69f0304a 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -67,6 +67,7 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js'; import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js'; import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js'; +import { packedMahjongRoomDetailedSchema } from '@/models/json-schema/mahjong-room.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -131,6 +132,7 @@ export const refs = { ChatRoom: packedChatRoomSchema, ChatRoomInvitation: packedChatRoomInvitationSchema, ChatRoomMembership: packedChatRoomMembershipSchema, + MahjongRoomDetailed: packedMahjongRoomDetailedSchema, }; export type Packed = SchemaType; diff --git a/packages/backend/src/models/MahjongGame.ts b/packages/backend/src/models/MahjongGame.ts new file mode 100644 index 0000000000..2735533ee0 --- /dev/null +++ b/packages/backend/src/models/MahjongGame.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('mahjong_game') +export class MiMahjongGame { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + nullable: true, + }) + public startedAt: Date | null; + + @Column('timestamp with time zone', { + nullable: true, + }) + public endedAt: Date | null; + + @Column({ + ...id(), + nullable: true, + }) + public user1Id: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user1: MiUser | null; + + @Column({ + ...id(), + nullable: true, + }) + public user2Id: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user2: MiUser | null; + + @Column({ + ...id(), + nullable: true, + }) + public user3Id: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user3: MiUser | null; + + @Column({ + ...id(), + nullable: true, + }) + public user4Id: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user4: MiUser | null; + + @Column('boolean', { + default: false, + }) + public isEnded: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public winnerId: MiUser['id'] | null; + + // in sec + @Column('smallint', { + default: 90, + }) + public timeLimitForEachTurn: number; + + @Column('jsonb', { + default: [], + }) + public logs: number[][]; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index b7142d91bf..eb097eafae 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -82,6 +82,7 @@ import { MiChatRoomMembership, MiChatRoomInvitation, MiChatApproval, + MiMahjongGame, } from './_.js'; import type { Provider } from '@nestjs/common'; import type { DataSource } from 'typeorm'; @@ -530,6 +531,12 @@ const $reversiGamesRepository: Provider = { inject: [DI.db], }; +const $mahjongGamesRepository: Provider = { + provide: DI.mahjongGamesRepository, + useFactory: (db: DataSource) => db.getRepository(MiMahjongGame), + inject: [DI.db], +}; + @Module({ imports: [], providers: [ @@ -607,6 +614,7 @@ const $reversiGamesRepository: Provider = { $chatApprovalsRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, + $mahjongGamesRepository, ], exports: [ $usersRepository, @@ -683,6 +691,7 @@ const $reversiGamesRepository: Provider = { $chatApprovalsRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, + $mahjongGamesRepository, ], }) export class RepositoryModule { diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index e1ea2a2604..75d623be0d 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -51,6 +51,7 @@ import { MiGalleryLike } from '@/models/GalleryLike.js'; import { MiGalleryPost } from '@/models/GalleryPost.js'; import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; +import { MiMahjongGame } from '@/models/MahjongGame.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; @@ -232,6 +233,7 @@ export { MiChatApproval, MiBubbleGameRecord, MiReversiGame, + MiMahjongGame, }; export type AbuseUserReportsRepository = Repository & MiRepository; @@ -310,3 +312,4 @@ export type ChatRoomInvitationsRepository = Repository & M export type ChatApprovalsRepository = Repository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; +export type MahjongGamesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/mahjong-room.ts b/packages/backend/src/models/json-schema/mahjong-room.ts new file mode 100644 index 0000000000..1627ee5728 --- /dev/null +++ b/packages/backend/src/models/json-schema/mahjong-room.ts @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedMahjongRoomDetailedSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + startedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + endedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + isStarted: { + type: 'boolean', + optional: false, nullable: false, + }, + isEnded: { + type: 'boolean', + optional: false, nullable: false, + }, + user1Id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user2Id: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + user3Id: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + user4Id: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + user1: { + type: 'object', + optional: false, nullable: true, + ref: 'User', + }, + user2: { + type: 'object', + optional: false, nullable: true, + ref: 'User', + }, + user3: { + type: 'object', + optional: false, nullable: true, + ref: 'User', + }, + user4: { + type: 'object', + optional: false, nullable: true, + ref: 'User', + }, + user1Ai: { + type: 'boolean', + optional: false, nullable: false, + }, + user2Ai: { + type: 'boolean', + optional: false, nullable: false, + }, + user3Ai: { + type: 'boolean', + optional: false, nullable: false, + }, + user4Ai: { + type: 'boolean', + optional: false, nullable: false, + }, + user1Ready: { + type: 'boolean', + optional: false, nullable: false, + }, + user2Ready: { + type: 'boolean', + optional: false, nullable: false, + }, + user3Ready: { + type: 'boolean', + optional: false, nullable: false, + }, + user4Ready: { + type: 'boolean', + optional: false, nullable: false, + }, + timeLimitForEachTurn: { + type: 'number', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index b06895fcc9..1ddd9c507f 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -84,6 +84,7 @@ import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiMahjongGame } from '@/models/MahjongGame.js'; import { MiChatApproval } from '@/models/ChatApproval.js'; import { MiSystemAccount } from '@/models/SystemAccount.js'; @@ -257,6 +258,7 @@ export const entities = [ MiChatApproval, MiBubbleGameRecord, MiReversiGame, + MiMahjongGame, ...charts, ]; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 0223650329..56c936d72f 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -48,6 +48,7 @@ import { ChatUserChannelService } from './api/stream/channels/chat-user.js'; import { ChatRoomChannelService } from './api/stream/channels/chat-room.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; +import { MahjongRoomChannelService } from './api/stream/channels/mahjong-room.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @Module({ @@ -90,6 +91,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ChatRoomChannelService, ReversiChannelService, ReversiGameChannelService, + MahjongRoomChannelService, HomeTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index e5170aa2dc..fb603c6b39 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -294,6 +294,12 @@ export * as 'invite/create' from './endpoints/invite/create.js'; export * as 'invite/delete' from './endpoints/invite/delete.js'; export * as 'invite/limit' from './endpoints/invite/limit.js'; export * as 'invite/list' from './endpoints/invite/list.js'; +export * as 'mahjong/cancel-match' from './endpoints/mahjong/cancel-match.js'; +export * as 'mahjong/create-room' from './endpoints/mahjong/create-room.js'; +export * as 'mahjong/games' from './endpoints/mahjong/games.js'; +export * as 'mahjong/join-room' from './endpoints/mahjong/join-room.js'; +export * as 'mahjong/show-room' from './endpoints/mahjong/show-room.js'; +export * as 'mahjong/verify' from './endpoints/mahjong/verify.js'; export * as 'meta' from './endpoints/meta.js'; export * as 'miauth/gen-token' from './endpoints/miauth/gen-token.js'; export * as 'mute/create' from './endpoints/mute/create.js'; diff --git a/packages/backend/src/server/api/endpoints/mahjong/cancel-match.ts b/packages/backend/src/server/api/endpoints/mahjong/cancel-match.ts new file mode 100644 index 0000000000..dd6f273e01 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mahjong/cancel-match.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ReversiService } from '@/core/ReversiService.js'; + +export const meta = { + requireCredential: true, + + kind: 'write:account', + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private reversiService: ReversiService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.userId) { + await this.reversiService.matchSpecificUserCancel(me, ps.userId); + return; + } else { + await this.reversiService.matchAnyUserCancel(me); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/mahjong/create-room.ts b/packages/backend/src/server/api/endpoints/mahjong/create-room.ts new file mode 100644 index 0000000000..0a991d343c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mahjong/create-room.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MahjongService } from '@/core/MahjongService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + requireCredential: true, + + kind: 'write:account', + + errors: { + + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'MahjongRoomDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private mahjongService: MahjongService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.mahjongService.createRoom(me); + return await this.mahjongService.packRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/mahjong/games.ts b/packages/backend/src/server/api/endpoints/mahjong/games.ts new file mode 100644 index 0000000000..6b06068727 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mahjong/games.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; +import { DI } from '@/di-symbols.js'; +import type { ReversiGamesRepository } from '@/models/_.js'; +import { QueryService } from '@/core/QueryService.js'; + +export const meta = { + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { ref: 'ReversiGameLite' }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + my: { type: 'boolean', default: false }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.reversiGamesRepository) + private reversiGamesRepository: ReversiGamesRepository, + + private reversiGameEntityService: ReversiGameEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('game.user1', 'user1') + .innerJoinAndSelect('game.user2', 'user2'); + + if (ps.my && me) { + query.andWhere(new Brackets(qb => { + qb + .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(); + + return await this.reversiGameEntityService.packLiteMany(games); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/mahjong/join-room.ts b/packages/backend/src/server/api/endpoints/mahjong/join-room.ts new file mode 100644 index 0000000000..ba1a22d9f7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mahjong/join-room.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MahjongService } from '@/core/MahjongService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '370e42b0-2a67-4306-9328-51c5f568f110', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'MahjongRoomDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private mahjongService: MahjongService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.mahjongService.getRoom(ps.roomId); + + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + await this.mahjongService.joinRoom(room.id, me); + + return await this.mahjongService.packRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/mahjong/show-room.ts b/packages/backend/src/server/api/endpoints/mahjong/show-room.ts new file mode 100644 index 0000000000..2562c19223 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mahjong/show-room.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { MahjongService } from '@/core/MahjongService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + requireCredential: true, + + kind: 'read:account', + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'd77df68f-06f3-492b-9078-e6f72f4acf23', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'MahjongRoomDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private mahjongService: MahjongService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.mahjongService.getRoom(ps.roomId); + + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + return await this.mahjongService.packRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/mahjong/verify.ts b/packages/backend/src/server/api/endpoints/mahjong/verify.ts new file mode 100644 index 0000000000..981735a3d7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mahjong/verify.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * 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/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index c0ef589dea..939e6e2e44 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -23,6 +23,7 @@ import { ChatUserChannelService } from './channels/chat-user.js'; import { ChatRoomChannelService } from './channels/chat-room.js'; import { ReversiChannelService } from './channels/reversi.js'; import { ReversiGameChannelService } from './channels/reversi-game.js'; +import { MahjongRoomChannelService } from './channels/mahjong-room.js'; import { type MiChannelService } from './channel.js'; @Injectable() @@ -46,6 +47,7 @@ export class ChannelsService { private chatRoomChannelService: ChatRoomChannelService, private reversiChannelService: ReversiChannelService, private reversiGameChannelService: ReversiGameChannelService, + private mahjongRoomChannelService: MahjongRoomChannelService, ) { } @@ -70,6 +72,7 @@ export class ChannelsService { case 'chatRoom': return this.chatRoomChannelService; case 'reversi': return this.reversiChannelService; case 'reversiGame': return this.reversiGameChannelService; + case 'mahjongRoom': return this.mahjongRoomChannelService; default: throw new Error(`no such channel: ${name}`); diff --git a/packages/backend/src/server/api/stream/channels/mahjong-room.ts b/packages/backend/src/server/api/stream/channels/mahjong-room.ts new file mode 100644 index 0000000000..3388b2f16f --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/mahjong-room.ts @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { MahjongService } from '@/core/MahjongService.js'; +import { GlobalEvents } from '@/core/GlobalEventService.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class MahjongRoomChannel extends Channel { + public readonly chName = 'mahjongRoom'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:account'; + private roomId: string | null = null; + + constructor( + private mahjongService: MahjongService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: any) { + this.roomId = params.roomId as string; + + this.subscriber.on(`mahjongRoomStream:${this.roomId}`, this.onMahjongRoomStreamMessage); + } + + @bindThis + private async onMahjongRoomStreamMessage(message: GlobalEvents['mahjongRoom']['payload']) { + if (message.type === 'started') { + const packed = await this.mahjongService.packRoom(message.body.room, this.user!); + this.send('started', { + room: packed, + }); + } else if (message.type === 'nextKyoku') { + const packed = this.mahjongService.packState(message.body.room, this.user!); + this.send('nextKyoku', { + state: packed, + }); + } else { + this.send(message.type, message.body); + } + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'ready': this.ready(body); break; + case 'updateSettings': this.updateSettings(body.key, body.value); break; + case 'addAi': this.addAi(); break; + case 'leave': this.leaveRoom(); break; + case 'confirmNextKyoku': this.confirmNextKyoku(); break; + case 'dahai': this.dahai(body.tile, body.riichi); break; + case 'tsumoHora': this.tsumoHora(); break; + case 'ronHora': this.ronHora(); break; + case 'pon': this.pon(); break; + case 'cii': this.cii(body.pattern); break; + case 'kan': this.kan(); break; + case 'ankan': this.ankan(body.tile); break; + case 'kakan': this.kakan(body.tile); break; + case 'nop': this.nop(); break; + case 'claimTimeIsUp': this.claimTimeIsUp(); break; + } + } + + @bindThis + private async updateSettings(key: string, value: any) { + if (this.user == null) return; + + this.mahjongService.updateSettings(this.roomId!, this.user, key, value); + } + + @bindThis + private async ready(ready: boolean) { + if (this.user == null) return; + + this.mahjongService.changeReadyState(this.roomId!, this.user, ready); + } + + @bindThis + private async confirmNextKyoku() { + if (this.user == null) return; + + this.mahjongService.confirmNextKyoku(this.roomId!, this.user); + } + + @bindThis + private async addAi() { + if (this.user == null) return; + + this.mahjongService.addAi(this.roomId!, this.user); + } + + @bindThis + private async leaveRoom() { + if (this.user == null) return; + + this.mahjongService.leaveRoom(this.roomId!, this.user); + } + + @bindThis + private async dahai(tile: number, riichi = false) { + if (this.user == null) return; + + this.mahjongService.commit_dahai(this.roomId!, this.user, tile, riichi); + } + + @bindThis + private async tsumoHora() { + if (this.user == null) return; + + this.mahjongService.commit_tsumoHora(this.roomId!, this.user); + } + + @bindThis + private async ronHora() { + if (this.user == null) return; + + this.mahjongService.commit_ronHora(this.roomId!, this.user); + } + + @bindThis + private async pon() { + if (this.user == null) return; + + this.mahjongService.commit_pon(this.roomId!, this.user); + } + + @bindThis + private async cii(pattern: string) { + if (this.user == null) return; + + this.mahjongService.commit_cii(this.roomId!, this.user, pattern); + } + +@bindThis + private async kan() { + if (this.user == null) return; + + this.mahjongService.commit_kan(this.roomId!, this.user); + } + + @bindThis +private async ankan(tile: number) { + if (this.user == null) return; + + this.mahjongService.commit_ankan(this.roomId!, this.user, tile); +} + + @bindThis + private async kakan(tile: number) { + if (this.user == null) return; + + this.mahjongService.commit_kakan(this.roomId!, this.user, tile); + } + + @bindThis + private async nop() { + if (this.user == null) return; + + this.mahjongService.commit_nop(this.roomId!, this.user); + } + + @bindThis + private async claimTimeIsUp() { + if (this.user == null) return; + + this.mahjongService.checkTimeout(this.roomId!); + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off(`mahjongRoomStream:${this.roomId}`, this.onMahjongRoomStreamMessage); + } +} + +@Injectable() +export class MahjongRoomChannelService implements MiChannelService { + public readonly shouldShare = MahjongRoomChannel.shouldShare; + public readonly requireCredential = MahjongRoomChannel.requireCredential; + public readonly kind = MahjongRoomChannel.kind; + + constructor( + private mahjongService: MahjongService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): MahjongRoomChannel { + return new MahjongRoomChannel( + this.mahjongService, + id, + connection, + ); + } +} diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml index 25770063d3..33e75841c0 100644 --- a/packages/backend/test-federation/compose.tpl.yml +++ b/packages/backend/test-federation/compose.tpl.yml @@ -50,6 +50,14 @@ services: source: ../../misskey-js/package.json target: /misskey/packages/misskey-js/package.json read_only: true + - type: bind + source: ../../misskey-mahjong/built + target: /misskey/packages/misskey-mahjong/built + read_only: true + - type: bind + source: ../../misskey-mahjong/package.json + target: /misskey/packages/misskey-mahjong/package.json + read_only: true - type: bind source: ../../misskey-reversi/built target: /misskey/packages/misskey-reversi/built diff --git a/packages/frontend/assets/mahjong/99.png b/packages/frontend/assets/mahjong/99.png new file mode 100644 index 0000000000..2e72339a65 Binary files /dev/null and b/packages/frontend/assets/mahjong/99.png differ diff --git a/packages/frontend/assets/mahjong/bg.jpg b/packages/frontend/assets/mahjong/bg.jpg new file mode 100644 index 0000000000..a4b4d07a1e Binary files /dev/null and b/packages/frontend/assets/mahjong/bg.jpg differ diff --git a/packages/frontend/assets/mahjong/cii.png b/packages/frontend/assets/mahjong/cii.png new file mode 100644 index 0000000000..d2da0a111d Binary files /dev/null and b/packages/frontend/assets/mahjong/cii.png differ diff --git a/packages/frontend/assets/mahjong/dahai.mp3 b/packages/frontend/assets/mahjong/dahai.mp3 new file mode 100644 index 0000000000..baa1b83195 Binary files /dev/null and b/packages/frontend/assets/mahjong/dahai.mp3 differ diff --git a/packages/frontend/assets/mahjong/kaisi.png b/packages/frontend/assets/mahjong/kaisi.png new file mode 100644 index 0000000000..322d2e08e3 Binary files /dev/null and b/packages/frontend/assets/mahjong/kaisi.png differ diff --git a/packages/frontend/assets/mahjong/kan.png b/packages/frontend/assets/mahjong/kan.png new file mode 100644 index 0000000000..3442df0004 Binary files /dev/null and b/packages/frontend/assets/mahjong/kan.png differ diff --git a/packages/frontend/assets/mahjong/logo.png b/packages/frontend/assets/mahjong/logo.png new file mode 100644 index 0000000000..6ebbdbb548 Binary files /dev/null and b/packages/frontend/assets/mahjong/logo.png differ diff --git a/packages/frontend/assets/mahjong/pon.png b/packages/frontend/assets/mahjong/pon.png new file mode 100644 index 0000000000..204e7fcd52 Binary files /dev/null and b/packages/frontend/assets/mahjong/pon.png differ diff --git a/packages/frontend/assets/mahjong/putted-tile-1.png b/packages/frontend/assets/mahjong/putted-tile-1.png new file mode 100644 index 0000000000..88de9a6412 Binary files /dev/null and b/packages/frontend/assets/mahjong/putted-tile-1.png differ diff --git a/packages/frontend/assets/mahjong/putted-tile-2.png b/packages/frontend/assets/mahjong/putted-tile-2.png new file mode 100644 index 0000000000..e3192b19f6 Binary files /dev/null and b/packages/frontend/assets/mahjong/putted-tile-2.png differ diff --git a/packages/frontend/assets/mahjong/putted-tile-3.png b/packages/frontend/assets/mahjong/putted-tile-3.png new file mode 100644 index 0000000000..bda7a5468d Binary files /dev/null and b/packages/frontend/assets/mahjong/putted-tile-3.png differ diff --git a/packages/frontend/assets/mahjong/putted-tile-4.png b/packages/frontend/assets/mahjong/putted-tile-4.png new file mode 100644 index 0000000000..dd6210a750 Binary files /dev/null and b/packages/frontend/assets/mahjong/putted-tile-4.png differ diff --git a/packages/frontend/assets/mahjong/putted-tile-5.png b/packages/frontend/assets/mahjong/putted-tile-5.png new file mode 100644 index 0000000000..4d65357353 Binary files /dev/null and b/packages/frontend/assets/mahjong/putted-tile-5.png differ diff --git a/packages/frontend/assets/mahjong/riichi.png b/packages/frontend/assets/mahjong/riichi.png new file mode 100644 index 0000000000..2860f5dc65 Binary files /dev/null and b/packages/frontend/assets/mahjong/riichi.png differ diff --git a/packages/frontend/assets/mahjong/ron.png b/packages/frontend/assets/mahjong/ron.png new file mode 100644 index 0000000000..c508f81adb Binary files /dev/null and b/packages/frontend/assets/mahjong/ron.png differ diff --git a/packages/frontend/assets/mahjong/ryuukyoku.png b/packages/frontend/assets/mahjong/ryuukyoku.png new file mode 100644 index 0000000000..1cdc4cce67 Binary files /dev/null and b/packages/frontend/assets/mahjong/ryuukyoku.png differ diff --git a/packages/frontend/assets/mahjong/tile-back.png b/packages/frontend/assets/mahjong/tile-back.png new file mode 100644 index 0000000000..8f4f495320 Binary files /dev/null and b/packages/frontend/assets/mahjong/tile-back.png differ diff --git a/packages/frontend/assets/mahjong/tile-side.png b/packages/frontend/assets/mahjong/tile-side.png new file mode 100644 index 0000000000..281e0a54df Binary files /dev/null and b/packages/frontend/assets/mahjong/tile-side.png differ diff --git a/packages/frontend/assets/mahjong/tiles/chun.png b/packages/frontend/assets/mahjong/tiles/chun.png new file mode 100644 index 0000000000..5d57fcc4ab Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/chun.png differ diff --git a/packages/frontend/assets/mahjong/tiles/e.png b/packages/frontend/assets/mahjong/tiles/e.png new file mode 100644 index 0000000000..0443b5046d Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/e.png differ diff --git a/packages/frontend/assets/mahjong/tiles/haku.png b/packages/frontend/assets/mahjong/tiles/haku.png new file mode 100644 index 0000000000..39603fb486 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/haku.png differ diff --git a/packages/frontend/assets/mahjong/tiles/hatsu.png b/packages/frontend/assets/mahjong/tiles/hatsu.png new file mode 100644 index 0000000000..6481e5d2ec Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/hatsu.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m1.png b/packages/frontend/assets/mahjong/tiles/m1.png new file mode 100644 index 0000000000..a514dd3df0 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m1.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m2.png b/packages/frontend/assets/mahjong/tiles/m2.png new file mode 100644 index 0000000000..5499cc3660 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m2.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m3.png b/packages/frontend/assets/mahjong/tiles/m3.png new file mode 100644 index 0000000000..6748b8734c Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m3.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m4.png b/packages/frontend/assets/mahjong/tiles/m4.png new file mode 100644 index 0000000000..82debd6789 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m4.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m5.png b/packages/frontend/assets/mahjong/tiles/m5.png new file mode 100644 index 0000000000..0f088c9893 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m5.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m5r.png b/packages/frontend/assets/mahjong/tiles/m5r.png new file mode 100644 index 0000000000..7347fb14ba Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m5r.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m6.png b/packages/frontend/assets/mahjong/tiles/m6.png new file mode 100644 index 0000000000..35c5972ec5 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m6.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m7.png b/packages/frontend/assets/mahjong/tiles/m7.png new file mode 100644 index 0000000000..a4929ebb1e Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m7.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m8.png b/packages/frontend/assets/mahjong/tiles/m8.png new file mode 100644 index 0000000000..2caddd9b19 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m8.png differ diff --git a/packages/frontend/assets/mahjong/tiles/m9.png b/packages/frontend/assets/mahjong/tiles/m9.png new file mode 100644 index 0000000000..1577fd2139 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/m9.png differ diff --git a/packages/frontend/assets/mahjong/tiles/n.png b/packages/frontend/assets/mahjong/tiles/n.png new file mode 100644 index 0000000000..9ab59fef2e Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/n.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p1.png b/packages/frontend/assets/mahjong/tiles/p1.png new file mode 100644 index 0000000000..a6f79386f5 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p1.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p2.png b/packages/frontend/assets/mahjong/tiles/p2.png new file mode 100644 index 0000000000..f5182ad582 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p2.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p3.png b/packages/frontend/assets/mahjong/tiles/p3.png new file mode 100644 index 0000000000..af2cd17fbf Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p3.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p4.png b/packages/frontend/assets/mahjong/tiles/p4.png new file mode 100644 index 0000000000..485dd20511 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p4.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p5.png b/packages/frontend/assets/mahjong/tiles/p5.png new file mode 100644 index 0000000000..6ee0ab6075 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p5.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p5r.png b/packages/frontend/assets/mahjong/tiles/p5r.png new file mode 100644 index 0000000000..6454624dce Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p5r.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p6.png b/packages/frontend/assets/mahjong/tiles/p6.png new file mode 100644 index 0000000000..a14a3f9f7f Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p6.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p7.png b/packages/frontend/assets/mahjong/tiles/p7.png new file mode 100644 index 0000000000..685559b936 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p7.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p8.png b/packages/frontend/assets/mahjong/tiles/p8.png new file mode 100644 index 0000000000..91017184a9 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p8.png differ diff --git a/packages/frontend/assets/mahjong/tiles/p9.png b/packages/frontend/assets/mahjong/tiles/p9.png new file mode 100644 index 0000000000..aebddf3d2c Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/p9.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s.png b/packages/frontend/assets/mahjong/tiles/s.png new file mode 100644 index 0000000000..d163b4deb3 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s1.png b/packages/frontend/assets/mahjong/tiles/s1.png new file mode 100644 index 0000000000..52c2fe3f83 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s1.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s2.png b/packages/frontend/assets/mahjong/tiles/s2.png new file mode 100644 index 0000000000..7f90ce68bb Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s2.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s3.png b/packages/frontend/assets/mahjong/tiles/s3.png new file mode 100644 index 0000000000..ffa7275f30 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s3.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s4.png b/packages/frontend/assets/mahjong/tiles/s4.png new file mode 100644 index 0000000000..75638ec7a2 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s4.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s5.png b/packages/frontend/assets/mahjong/tiles/s5.png new file mode 100644 index 0000000000..18d1dd296b Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s5.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s5r.png b/packages/frontend/assets/mahjong/tiles/s5r.png new file mode 100644 index 0000000000..09b1b884bf Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s5r.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s6.png b/packages/frontend/assets/mahjong/tiles/s6.png new file mode 100644 index 0000000000..578f7c495f Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s6.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s7.png b/packages/frontend/assets/mahjong/tiles/s7.png new file mode 100644 index 0000000000..077c5c0a12 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s7.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s8.png b/packages/frontend/assets/mahjong/tiles/s8.png new file mode 100644 index 0000000000..187cb89ceb Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s8.png differ diff --git a/packages/frontend/assets/mahjong/tiles/s9.png b/packages/frontend/assets/mahjong/tiles/s9.png new file mode 100644 index 0000000000..2c4134ecdb Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/s9.png differ diff --git a/packages/frontend/assets/mahjong/tiles/w.png b/packages/frontend/assets/mahjong/tiles/w.png new file mode 100644 index 0000000000..ac576f8a47 Binary files /dev/null and b/packages/frontend/assets/mahjong/tiles/w.png differ diff --git a/packages/frontend/assets/mahjong/tsumo.png b/packages/frontend/assets/mahjong/tsumo.png new file mode 100644 index 0000000000..547d01cbb7 Binary files /dev/null and b/packages/frontend/assets/mahjong/tsumo.png differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index a771885bcb..ba20b18717 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -56,6 +56,7 @@ "matter-js": "0.20.0", "mfm-js": "0.24.0", "misskey-bubble-game": "workspace:*", + "misskey-mahjong": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue index 12b84d19aa..0ae011313a 100644 --- a/packages/frontend/src/pages/games.vue +++ b/packages/frontend/src/pages/games.vue @@ -17,6 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only +
+ + + +
diff --git a/packages/frontend/src/pages/mahjong/hand-tiles.vue b/packages/frontend/src/pages/mahjong/hand-tiles.vue new file mode 100644 index 0000000000..bc7c857138 --- /dev/null +++ b/packages/frontend/src/pages/mahjong/hand-tiles.vue @@ -0,0 +1,184 @@ + + + + + + + diff --git a/packages/frontend/src/pages/mahjong/huro.vue b/packages/frontend/src/pages/mahjong/huro.vue new file mode 100644 index 0000000000..487999217e --- /dev/null +++ b/packages/frontend/src/pages/mahjong/huro.vue @@ -0,0 +1,46 @@ + + + + + + + diff --git a/packages/frontend/src/pages/mahjong/index.vue b/packages/frontend/src/pages/mahjong/index.vue new file mode 100644 index 0000000000..a83bb1ec31 --- /dev/null +++ b/packages/frontend/src/pages/mahjong/index.vue @@ -0,0 +1,166 @@ + + + + + + + diff --git a/packages/frontend/src/pages/mahjong/room.game.vue b/packages/frontend/src/pages/mahjong/room.game.vue new file mode 100644 index 0000000000..20cec2dd14 --- /dev/null +++ b/packages/frontend/src/pages/mahjong/room.game.vue @@ -0,0 +1,1214 @@ + + + + + + + diff --git a/packages/frontend/src/pages/mahjong/room.setting.vue b/packages/frontend/src/pages/mahjong/room.setting.vue new file mode 100644 index 0000000000..29504013f6 --- /dev/null +++ b/packages/frontend/src/pages/mahjong/room.setting.vue @@ -0,0 +1,165 @@ + + + + + + + diff --git a/packages/frontend/src/pages/mahjong/room.vue b/packages/frontend/src/pages/mahjong/room.vue new file mode 100644 index 0000000000..b4962d2a53 --- /dev/null +++ b/packages/frontend/src/pages/mahjong/room.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/packages/frontend/src/pages/mahjong/tile.vue b/packages/frontend/src/pages/mahjong/tile.vue new file mode 100644 index 0000000000..aa7ec8f6d2 --- /dev/null +++ b/packages/frontend/src/pages/mahjong/tile.vue @@ -0,0 +1,78 @@ + + + + + + + diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index a0a22b4338..acfd499699 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -579,6 +579,14 @@ export const ROUTE_DEF = [{ path: '/reversi/g/:gameId', component: page(() => import('@/pages/reversi/game.vue')), loginRequired: false, +}, { + path: '/mahjong', + component: page(() => import('@/pages/mahjong/index.vue')), + loginRequired: false, +}, { + path: '/mahjong/g/:roomId', + component: page(() => import('@/pages/mahjong/room.vue')), + loginRequired: true, }, { path: '/timeline', component: page(() => import('@/pages/timeline.vue')), diff --git a/packages/frontend/src/utility/mahjong.ts b/packages/frontend/src/utility/mahjong.ts new file mode 100644 index 0000000000..b3704fba74 --- /dev/null +++ b/packages/frontend/src/utility/mahjong.ts @@ -0,0 +1,83 @@ +export const TILE_TYPES = [ + 'bamboo1', + 'bamboo2', + 'bamboo3', + 'bamboo4', + 'bamboo5', + 'bamboo6', + 'bamboo7', + 'bamboo8', + 'bamboo9', + 'character1', + 'character2', + 'character3', + 'character4', + 'character5', + 'character6', + 'character7', + 'character8', + 'character9', + 'circle1', + 'circle2', + 'circle3', + 'circle4', + 'circle5', + 'circle6', + 'circle7', + 'circle8', + 'circle9', + 'wind-east', + 'wind-south', + 'wind-west', + 'wind-north', + 'dragon-red', + 'dragon-green', + 'dragon-white', +]; + +type Player = 'east' | 'south' | 'west' | 'north'; + +export class MahjongGameForBackend { + public tiles: (typeof TILE_TYPES[number])[] = []; + public 場: (typeof TILE_TYPES[number])[] = []; + public playerEastTiles: (typeof TILE_TYPES[number])[] = []; + public playerSouthTiles: (typeof TILE_TYPES[number])[] = []; + public playerWestTiles: (typeof TILE_TYPES[number])[] = []; + public playerNorthTiles: (typeof TILE_TYPES[number])[] = []; + public turn: Player = 'east'; + + constructor() { + this.tiles = TILE_TYPES.slice(); + this.shuffleTiles(); + } + + public shuffleTiles() { + this.tiles.sort(() => Math.random() - 0.5); + } + + public drawTile(): typeof TILE_TYPES[number] { + return this.tiles.pop()!; + } + + public operation_drop(player: Player, tile: typeof TILE_TYPES[number]) { + if (this.turn !== player) { + throw new Error('Not your turn'); + } + + switch (player) { + case 'east': + this.playerEastTiles.splice(this.playerEastTiles.indexOf(tile), 1); + break; + case 'south': + this.playerSouthTiles.splice(this.playerSouthTiles.indexOf(tile), 1); + break; + case 'west': + this.playerWestTiles.splice(this.playerWestTiles.indexOf(tile), 1); + break; + case 'north': + this.playerNorthTiles.splice(this.playerNorthTiles.indexOf(tile), 1); + break; + } + this.場.push(tile); + } +} diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index aa7bf24174..53cd7cc3ca 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -194,7 +194,7 @@ export function getConfig(): UserConfig { // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies commonjsOptions: { - include: [/misskey-js/, /misskey-reversi/, /misskey-bubble-game/, /node_modules/], + include: [/misskey-js/, /misskey-reversi/, /misskey-bubble-game/, /misskey-mahjong/, /node_modules/], }, }, diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index b43906109f..a31b9dd8aa 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -879,6 +879,111 @@ export type Channels = { }; }; }; + mahjongRoom: { + params: { + roomId: string; + }; + events: { + joined: (payload: { + index: number; + user: UserLite | null; + }) => void; + changeReadyStates: (payload: { + user1: boolean; + user2: boolean; + user3: boolean; + user4: boolean; + }) => void; + started: (payload: { + room: MahjongRoomDetailed; + }) => void; + nextKyoku: (payload: { + room: MahjongRoomDetailed; + }) => void; + tsumo: (payload: { + house: MmjHouse; + tile: number; + }) => void; + dahai: (payload: { + house: MmjHouse; + tile: number; + riichi: boolean; + }) => void; + dahaiAndTsumo: (payload: { + dahaiHouse: MmjHouse; + dahaiTile: number; + tsumoTile: number; + riichi: boolean; + }) => void; + ponned: (payload: { + caller: MmjHouse; + callee: MmjHouse; + tiles: readonly [number, number, number]; + }) => void; + kanned: (payload: { + caller: MmjHouse; + callee: MmjHouse; + tiles: readonly [number, number, number, number]; + rinsyan: number; + }) => void; + ciied: (payload: { + caller: MmjHouse; + callee: MmjHouse; + tiles: readonly [number, number, number]; + }) => void; + ronned: (payload: { + callers: MmjHouse[]; + callee: MmjHouse; + handTiles: Record; + }) => void; + ryuukyoku: (payload: unknown) => void; + ankanned: (payload: { + house: MmjHouse; + tiles: readonly [number, number, number, number]; + rinsyan: number; + }) => void; + kakanned: (payload: { + house: MmjHouse; + tiles: readonly [number, number, number, number]; + rinsyan: number; + from: MmjHouse; + }) => void; + tsumoHora: (payload: { + house: MmjHouse; + handTiles: number[]; + tsumoTile: number; + }) => void; + }; + receives: { + ready: boolean; + updateSettings: { + key: string; + body: unknown; + }; + addAi: Record; + leave: Record; + confirmNextKyoku: Record; + dahai: { + tile: number; + riichi?: boolean; + }; + tsumoHora: Record; + ronHora: Record; + pon: Record; + cii: { + pattern: string; + }; + kan: Record; + ankan: { + tile: number; + }; + kakan: { + tile: number; + }; + nop: Record; + claimTimeIsUp: Record; + }; + }; }; // @public (undocumented) @@ -1938,6 +2043,16 @@ declare namespace entities { InviteLimitResponse, InviteListRequest, InviteListResponse, + MahjongCancelMatchRequest, + MahjongCreateRoomResponse, + MahjongGamesRequest, + MahjongGamesResponse, + MahjongJoinRoomRequest, + MahjongJoinRoomResponse, + MahjongShowRoomRequest, + MahjongShowRoomResponse, + MahjongVerifyRequest, + MahjongVerifyResponse, MetaRequest, MetaResponse, MiauthGenTokenRequest, @@ -2168,7 +2283,8 @@ declare namespace entities { ChatMessageLiteForRoom, ChatRoom, ChatRoomInvitation, - ChatRoomMembership + ChatRoomMembership, + MahjongRoomDetailed } } export { entities } @@ -2737,6 +2853,39 @@ type IWebhooksTestRequest = operations['i___webhooks___test']['requestBody']['co // @public (undocumented) type IWebhooksUpdateRequest = operations['i___webhooks___update']['requestBody']['content']['application/json']; +// @public (undocumented) +type MahjongCancelMatchRequest = operations['mahjong___cancel-match']['requestBody']['content']['application/json']; + +// @public (undocumented) +type MahjongCreateRoomResponse = operations['mahjong___create-room']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type MahjongGamesRequest = operations['mahjong___games']['requestBody']['content']['application/json']; + +// @public (undocumented) +type MahjongGamesResponse = operations['mahjong___games']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type MahjongJoinRoomRequest = operations['mahjong___join-room']['requestBody']['content']['application/json']; + +// @public (undocumented) +type MahjongJoinRoomResponse = operations['mahjong___join-room']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type MahjongRoomDetailed = components['schemas']['MahjongRoomDetailed']; + +// @public (undocumented) +type MahjongShowRoomRequest = operations['mahjong___show-room']['requestBody']['content']['application/json']; + +// @public (undocumented) +type MahjongShowRoomResponse = operations['mahjong___show-room']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type MahjongVerifyRequest = operations['mahjong___verify']['requestBody']['content']['application/json']; + +// @public (undocumented) +type MahjongVerifyResponse = operations['mahjong___verify']['responses']['200']['content']['application/json']; + // @public (undocumented) type MeDetailed = components['schemas']['MeDetailed']; @@ -3735,8 +3884,9 @@ type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['respons // // src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.ts:57:3 - (ae-forgotten-export) The symbol "ReconnectingWebSocket" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:218:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:228:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts +// src/streaming.types.ts:221:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts +// src/streaming.types.ts:231:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts +// src/streaming.types.ts:301:5 - (ae-forgotten-export) The symbol "MmjHouse" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index b607c93e1e..6d9abaa9c7 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3438,6 +3438,72 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 6390314429..5f76c2c572 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -470,6 +470,16 @@ import type { InviteLimitResponse, InviteListRequest, InviteListResponse, + MahjongCancelMatchRequest, + MahjongCreateRoomResponse, + MahjongGamesRequest, + MahjongGamesResponse, + MahjongJoinRoomRequest, + MahjongJoinRoomResponse, + MahjongShowRoomRequest, + MahjongShowRoomResponse, + MahjongVerifyRequest, + MahjongVerifyResponse, MetaRequest, MetaResponse, MiauthGenTokenRequest, @@ -950,6 +960,12 @@ export type Endpoints = { 'invite/delete': { req: InviteDeleteRequest; res: EmptyResponse }; 'invite/limit': { req: EmptyRequest; res: InviteLimitResponse }; 'invite/list': { req: InviteListRequest; res: InviteListResponse }; + 'mahjong/cancel-match': { req: MahjongCancelMatchRequest; res: EmptyResponse }; + 'mahjong/create-room': { req: EmptyRequest; res: MahjongCreateRoomResponse }; + 'mahjong/games': { req: MahjongGamesRequest; res: MahjongGamesResponse }; + 'mahjong/join-room': { req: MahjongJoinRoomRequest; res: MahjongJoinRoomResponse }; + 'mahjong/show-room': { req: MahjongShowRoomRequest; res: MahjongShowRoomResponse }; + 'mahjong/verify': { req: MahjongVerifyRequest; res: MahjongVerifyResponse }; 'meta': { req: MetaRequest; res: MetaResponse }; 'miauth/gen-token': { req: MiauthGenTokenRequest; res: MiauthGenTokenResponse }; 'mute/create': { req: MuteCreateRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index f814d7b3da..79ea35136e 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -473,6 +473,16 @@ export type InviteDeleteRequest = operations['invite___delete']['requestBody'][' export type InviteLimitResponse = operations['invite___limit']['responses']['200']['content']['application/json']; export type InviteListRequest = operations['invite___list']['requestBody']['content']['application/json']; export type InviteListResponse = operations['invite___list']['responses']['200']['content']['application/json']; +export type MahjongCancelMatchRequest = operations['mahjong___cancel-match']['requestBody']['content']['application/json']; +export type MahjongCreateRoomResponse = operations['mahjong___create-room']['responses']['200']['content']['application/json']; +export type MahjongGamesRequest = operations['mahjong___games']['requestBody']['content']['application/json']; +export type MahjongGamesResponse = operations['mahjong___games']['responses']['200']['content']['application/json']; +export type MahjongJoinRoomRequest = operations['mahjong___join-room']['requestBody']['content']['application/json']; +export type MahjongJoinRoomResponse = operations['mahjong___join-room']['responses']['200']['content']['application/json']; +export type MahjongShowRoomRequest = operations['mahjong___show-room']['requestBody']['content']['application/json']; +export type MahjongShowRoomResponse = operations['mahjong___show-room']['responses']['200']['content']['application/json']; +export type MahjongVerifyRequest = operations['mahjong___verify']['requestBody']['content']['application/json']; +export type MahjongVerifyResponse = operations['mahjong___verify']['responses']['200']['content']['application/json']; export type MetaRequest = operations['meta']['requestBody']['content']['application/json']; export type MetaResponse = operations['meta']['responses']['200']['content']['application/json']; export type MiauthGenTokenRequest = operations['miauth___gen-token']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 15c3ee7e55..2a3d12c441 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -61,3 +61,4 @@ export type ChatMessageLiteForRoom = components['schemas']['ChatMessageLiteForRo export type ChatRoom = components['schemas']['ChatRoom']; export type ChatRoomInvitation = components['schemas']['ChatRoomInvitation']; export type ChatRoomMembership = components['schemas']['ChatRoomMembership']; +export type MahjongRoomDetailed = components['schemas']['MahjongRoomDetailed']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 9da5540bc1..9c6db34180 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -2971,6 +2971,60 @@ export type paths = { */ post: operations['invite___list']; }; + '/mahjong/cancel-match': { + /** + * mahjong/cancel-match + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + post: operations['mahjong___cancel-match']; + }; + '/mahjong/create-room': { + /** + * mahjong/create-room + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + post: operations['mahjong___create-room']; + }; + '/mahjong/games': { + /** + * mahjong/games + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['mahjong___games']; + }; + '/mahjong/join-room': { + /** + * mahjong/join-room + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + post: operations['mahjong___join-room']; + }; + '/mahjong/show-room': { + /** + * mahjong/show-room + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + post: operations['mahjong___show-room']; + }; + '/mahjong/verify': { + /** + * mahjong/verify + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['mahjong___verify']; + }; '/meta': { /** * meta @@ -5541,6 +5595,39 @@ export type components = { roomId: string; room?: components['schemas']['ChatRoom']; }; + MahjongRoomDetailed: { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + startedAt: string | null; + /** Format: date-time */ + endedAt: string | null; + isStarted: boolean; + isEnded: boolean; + /** Format: id */ + user1Id: string; + /** Format: id */ + user2Id: string | null; + /** Format: id */ + user3Id: string | null; + /** Format: id */ + user4Id: string | null; + user1: components['schemas']['User'] | null; + user2: components['schemas']['User'] | null; + user3: components['schemas']['User'] | null; + user4: components['schemas']['User'] | null; + user1Ai: boolean; + user2Ai: boolean; + user3Ai: boolean; + user4Ai: boolean; + user1Ready: boolean; + user2Ready: boolean; + user3Ready: boolean; + user4Ready: boolean; + timeLimitForEachTurn: number; + }; }; responses: never; parameters: never; @@ -23978,6 +24065,330 @@ export type operations = { }; }; }; + /** + * mahjong/cancel-match + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + 'mahjong___cancel-match': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + userId?: string | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * mahjong/create-room + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + 'mahjong___create-room': { + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['MahjongRoomDetailed']; + }; + }; + /** @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']; + }; + }; + }; + }; + /** + * mahjong/games + * @description No description provided. + * + * **Credential required**: *No* + */ + mahjong___games: { + requestBody: { + content: { + 'application/json': { + /** @default 10 */ + limit?: number; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** @default false */ + my?: boolean; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['ReversiGameLite'][]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * mahjong/join-room + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + 'mahjong___join-room': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + roomId: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['MahjongRoomDetailed']; + }; + }; + /** @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']; + }; + }; + }; + }; + /** + * mahjong/show-room + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + 'mahjong___show-room': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + roomId: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['MahjongRoomDetailed']; + }; + }; + /** @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']; + }; + }; + }; + }; + /** + * mahjong/verify + * @description No description provided. + * + * **Credential required**: *No* + */ + mahjong___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']; + }; + }; + }; + }; /** * meta * @description No description provided. diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 10204fb2c9..31ae1b0bce 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -4,6 +4,7 @@ import { ChatMessageLite, DriveFile, DriveFolder, + MahjongRoomDetailed, Note, Notification, Signin, @@ -32,6 +33,8 @@ type ReversiUpdateSettings = { value: ReversiGameDetailed[K]; }; +type MmjHouse = 'e' | 's' | 'w' | 'n'; + export type Channels = { main: { params: null; @@ -277,6 +280,107 @@ export type Channels = { }; }; }; + mahjongRoom: { + params: { + roomId: string; + }; + events: { + joined: (payload: { + index: number; + user: UserLite | null; + }) => void; + changeReadyStates: (payload: { + user1: boolean; + user2: boolean; + user3: boolean; + user4: boolean; + }) => void; + started: (payload: { room: MahjongRoomDetailed }) => void; + nextKyoku: (payload: { room: MahjongRoomDetailed }) => void; + tsumo: (payload: { + house: MmjHouse; + tile: number; + }) => void; + dahai: (payload: { + house: MmjHouse; + tile: number; + riichi: boolean; + }) => void; + dahaiAndTsumo: (payload: { + dahaiHouse: MmjHouse; + dahaiTile: number; + tsumoTile: number; + riichi: boolean; + }) => void; + ponned: (payload: { + caller: MmjHouse; + callee: MmjHouse; + tiles: readonly [number, number, number]; + }) => void; + kanned: (payload: { + caller: MmjHouse; + callee: MmjHouse; + tiles: readonly [number, number, number, number]; + rinsyan: number; + }) => void; + ciied: (payload: { + caller: MmjHouse; + callee: MmjHouse; + tiles: readonly [number, number, number]; + }) => void; + ronned: (payload: { + callers: MmjHouse[]; + callee: MmjHouse; + handTiles: Record; + }) => void; + ryuukyoku: (payload: unknown) => void; + ankanned: (payload: { + house: MmjHouse; + tiles: readonly [number, number, number, number]; + rinsyan: number; + }) => void; + kakanned: (payload: { + house: MmjHouse; + tiles: readonly [number, number, number, number]; + rinsyan: number; + from: MmjHouse; + }) => void; + tsumoHora: (payload: { + house: MmjHouse; + handTiles: number[]; + tsumoTile: number; + }) => void; + }; + receives: { + ready: boolean; + updateSettings: { + key: string; + body: unknown; + }; + addAi: Record; + leave: Record; + confirmNextKyoku: Record; + dahai: { + tile: number; + riichi?: boolean; + }; + tsumoHora: Record; + ronHora: Record; + pon: Record; + cii: { + pattern: string; + }; + kan: Record; + ankan: { + tile: number; + }; + kakan: { + tile: number; + }; + nop: Record; + claimTimeIsUp: Record; + }; + }; }; export type NoteUpdatedEvent = { id: Note['id'] } & ({ diff --git a/packages/misskey-mahjong/build.js b/packages/misskey-mahjong/build.js new file mode 100644 index 0000000000..293557ed83 --- /dev/null +++ b/packages/misskey-mahjong/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-mahjong/eslint.config.js b/packages/misskey-mahjong/eslint.config.js new file mode 100644 index 0000000000..57e25e9ddc --- /dev/null +++ b/packages/misskey-mahjong/eslint.config.js @@ -0,0 +1,36 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + 'built', + 'coverage', + 'jest.config.ts', + ], + }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.test.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/misskey-mahjong/jest.config.cjs b/packages/misskey-mahjong/jest.config.cjs new file mode 100644 index 0000000000..4c87106bd6 --- /dev/null +++ b/packages/misskey-mahjong/jest.config.cjs @@ -0,0 +1,212 @@ +/* +* For a detailed explanation regarding each configuration property and type check, visit: +* https://jestjs.io/docs/en/configuration.html +*/ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\ai\\AppData\\Local\\Temp\\jest", + + // Automatically clear mock calls and instances between every test + // clearMocks: false, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ['src/**/*.ts'], + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + //globals: { + //"ts-jest": { + //"useESM": true, + //diagnostics: { + //exclude: ['!test/**/*.ts'], + //}, + //} + //}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + preset: "ts-jest/presets/js-with-ts-esm", + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + resolver: "ts-jest-resolver", + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + roots: [ + "" + ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: [ + "**/__tests__/**/*.[jt]s?(x)", + "**/?(*.)+(spec|test).[tj]s?(x)", + "/test/**/*" + ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + transform: { + "^.+\\.(ts|tsx)$": [ + "ts-jest", + { + "useESM": true, + diagnostics: { + exclude: ['!test/**/*.ts'], + }, + }, + ], + }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true +}; diff --git a/packages/misskey-mahjong/package.json b/packages/misskey-mahjong/package.json new file mode 100644 index 0000000000..7ca8b8a36d --- /dev/null +++ b/packages/misskey-mahjong/package.json @@ -0,0 +1,49 @@ +{ + "type": "module", + "name": "misskey-mahjong", + "version": "0.0.1", + "types": "./built/dts/index.d.ts", + "exports": { + ".": { + "import": "./built/esm/index.js", + "types": "./built/dts/index.d.ts" + }, + "./*": { + "import": "./built/esm/*", + "types": "./built/dts/*" + } + }, + "scripts": { + "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", + "jest": "jest --coverage --detectOpenHandles", + "test": "npm run jest" + }, + "devDependencies": { + "@misskey-dev/eslint-plugin": "1.0.0", + "@types/node": "20.11.17", + "@types/jest": "29.5.12", + "@typescript-eslint/eslint-plugin": "6.18.1", + "@typescript-eslint/parser": "6.18.1", + "nodemon": "3.0.2", + "typescript": "5.3.3", + "jest": "29.7.0", + "ts-jest": "29.1.2", + "ts-jest-resolver": "2.0.1" + }, + "files": [ + "built" + ], + "dependencies": { + "crc-32": "1.2.2", + "esbuild": "0.19.11", + "glob": "10.3.10" + } +} diff --git a/packages/misskey-mahjong/src/common.fu.ts b/packages/misskey-mahjong/src/common.fu.ts new file mode 100644 index 0000000000..56e5dd61e6 --- /dev/null +++ b/packages/misskey-mahjong/src/common.fu.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FourMentsuOneJyantou, mentsuEquals, TILE_NUMBER_MAP, TileType } from './common.js'; + +export type Shape = 'fourMentsuOneJyantou' | 'chitoitsu' | 'kokushi'; + +/** + * 4面子1雀頭と待ちに関わる部分 + */ +export type FourMentsuOneJyantouWithWait = FourMentsuOneJyantou & { + agariTile: TileType; +} & ({ + waitedFor: 'head'; +} | { + waitedFor: 'mentsu'; + waitedTaatsu: [TileType, TileType]; +}); + +export function calcWaitPatterns(fourMentsuOneJyantou: FourMentsuOneJyantou | null, agariTile: TileType): FourMentsuOneJyantouWithWait[] | [null] { + if (fourMentsuOneJyantou == null) return [null]; + + const result: FourMentsuOneJyantouWithWait[] = []; + + if (fourMentsuOneJyantou.head === agariTile) { + result.push({ + head: fourMentsuOneJyantou.head, + mentsus: fourMentsuOneJyantou.mentsus, + waitedFor: 'head', + agariTile, + }); + } + + const checkedMentsus: [TileType, TileType, TileType][] = []; + for (const mentsu of fourMentsuOneJyantou.mentsus) { + if (checkedMentsus.some(checkedMentsu => mentsuEquals(mentsu, checkedMentsu))) continue; + const agariTileIndex = mentsu.indexOf(agariTile); + if (agariTileIndex < 0) continue; + result.push({ + head: fourMentsuOneJyantou.head, + mentsus: fourMentsuOneJyantou.mentsus, + waitedFor: 'mentsu', + agariTile, + waitedTaatsu: mentsu.toSpliced(agariTileIndex, 1) as [TileType, TileType], + }); + checkedMentsus.push(mentsu); + } + + return result; +} + +export function isRyanmen(taatsu: [TileType, TileType]): boolean { + const number1 = TILE_NUMBER_MAP[taatsu[0]]; + const number2 = TILE_NUMBER_MAP[taatsu[1]]; + if (number1 == null || number2 == null) return false; + return number1 !== 1 && number2 !== 9 && number1 + 1 === number2; +} + +export function isToitsu(taatsu: [TileType, TileType]): boolean { + return taatsu[0] === taatsu[1]; +} diff --git a/packages/misskey-mahjong/src/common.ts b/packages/misskey-mahjong/src/common.ts new file mode 100644 index 0000000000..3146c26f70 --- /dev/null +++ b/packages/misskey-mahjong/src/common.ts @@ -0,0 +1,691 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// NOTE: アガリ形の判定に使われるため並び順が重要 +// 具体的には、文字列としてソートした際に同じ牌種の1~9が順に並んでいる必要がある +// また、字牌は最後にある必要がある +export const TILE_TYPES = [ + 'm1', + 'm2', + 'm3', + 'm4', + 'm5', + 'm6', + 'm7', + 'm8', + 'm9', + 'p1', + 'p2', + 'p3', + 'p4', + 'p5', + 'p6', + 'p7', + 'p8', + 'p9', + 's1', + 's2', + 's3', + 's4', + 's5', + 's6', + 's7', + 's8', + 's9', + 'e', + 's', + 'w', + 'n', + 'haku', + 'hatsu', + 'chun', +] as const; + +export type TileType = typeof TILE_TYPES[number]; + +export type TileInstance = { + t: TileType; + red?: boolean; +}; + +export type TileId = number; + +// NOTE: 0 は"不明"(他プレイヤーの手牌など)を表すものとして予約されている +export const TILE_ID_MAP = new Map([ + /* eslint-disable @stylistic/no-multi-spaces */ + [1, { t: 'm1' }], [2, { t: 'm1' }], [3, { t: 'm1' }], [4, { t: 'm1' }], + [5, { t: 'm2' }], [6, { t: 'm2' }], [7, { t: 'm2' }], [8, { t: 'm2' }], + [9, { t: 'm3' }], [10, { t: 'm3' }], [11, { t: 'm3' }], [12, { t: 'm3' }], + [13, { t: 'm4' }], [14, { t: 'm4' }], [15, { t: 'm4' }], [16, { t: 'm4' }], + [17, { t: 'm5' }], [18, { t: 'm5' }], [19, { t: 'm5' }], [20, { t: 'm5', red: true }], + [21, { t: 'm6' }], [22, { t: 'm6' }], [23, { t: 'm6' }], [24, { t: 'm6' }], + [25, { t: 'm7' }], [26, { t: 'm7' }], [27, { t: 'm7' }], [28, { t: 'm7' }], + [29, { t: 'm8' }], [30, { t: 'm8' }], [31, { t: 'm8' }], [32, { t: 'm8' }], + [33, { t: 'm9' }], [34, { t: 'm9' }], [35, { t: 'm9' }], [36, { t: 'm9' }], + [37, { t: 'p1' }], [38, { t: 'p1' }], [39, { t: 'p1' }], [40, { t: 'p1' }], + [41, { t: 'p2' }], [42, { t: 'p2' }], [43, { t: 'p2' }], [44, { t: 'p2' }], + [45, { t: 'p3' }], [46, { t: 'p3' }], [47, { t: 'p3' }], [48, { t: 'p3' }], + [49, { t: 'p4' }], [50, { t: 'p4' }], [51, { t: 'p4' }], [52, { t: 'p4' }], + [53, { t: 'p5' }], [54, { t: 'p5' }], [55, { t: 'p5' }], [56, { t: 'p5', red: true }], + [57, { t: 'p6' }], [58, { t: 'p6' }], [59, { t: 'p6' }], [60, { t: 'p6' }], + [61, { t: 'p7' }], [62, { t: 'p7' }], [63, { t: 'p7' }], [64, { t: 'p7' }], + [65, { t: 'p8' }], [66, { t: 'p8' }], [67, { t: 'p8' }], [68, { t: 'p8' }], + [69, { t: 'p9' }], [70, { t: 'p9' }], [71, { t: 'p9' }], [72, { t: 'p9' }], + [73, { t: 's1' }], [74, { t: 's1' }], [75, { t: 's1' }], [76, { t: 's1' }], + [77, { t: 's2' }], [78, { t: 's2' }], [79, { t: 's2' }], [80, { t: 's2' }], + [81, { t: 's3' }], [82, { t: 's3' }], [83, { t: 's3' }], [84, { t: 's3' }], + [85, { t: 's4' }], [86, { t: 's4' }], [87, { t: 's4' }], [88, { t: 's4' }], + [89, { t: 's5' }], [90, { t: 's5' }], [91, { t: 's5' }], [92, { t: 's5', red: true }], + [93, { t: 's6' }], [94, { t: 's6' }], [95, { t: 's6' }], [96, { t: 's6' }], + [97, { t: 's7' }], [98, { t: 's7' }], [99, { t: 's7' }], [100, { t: 's7' }], + [101, { t: 's8' }], [102, { t: 's8' }], [103, { t: 's8' }], [104, { t: 's8' }], + [105, { t: 's9' }], [106, { t: 's9' }], [107, { t: 's9' }], [108, { t: 's9' }], + [109, { t: 'e' }], [110, { t: 'e' }], [111, { t: 'e' }], [112, { t: 'e' }], + [113, { t: 's' }], [114, { t: 's' }], [115, { t: 's' }], [116, { t: 's' }], + [117, { t: 'w' }], [118, { t: 'w' }], [119, { t: 'w' }], [120, { t: 'w' }], + [121, { t: 'n' }], [122, { t: 'n' }], [123, { t: 'n' }], [124, { t: 'n' }], + [125, { t: 'haku' }], [126, { t: 'haku' }], [127, { t: 'haku' }], [128, { t: 'haku' }], + [129, { t: 'hatsu' }], [130, { t: 'hatsu' }], [131, { t: 'hatsu' }], [132, { t: 'hatsu' }], + [133, { t: 'chun' }], [134, { t: 'chun' }], [135, { t: 'chun' }], [136, { t: 'chun' }], + /* eslint-enable @stylistic/no-multi-spaces */ +]); + +export function findTileByIdOrFail(tid: TileId): TileInstance { + const tile = TILE_ID_MAP.get(tid); + if (tile == null) throw new Error(`tile not found: ${tid}`); + return tile; +} + +export function findTileById(tid: TileId): TileInstance | null { + return TILE_ID_MAP.get(tid) ?? null; +} + +export type House = 'e' | 's' | 'w' | 'n'; + +/** + * 暗槓を含む + */ +export type Huro = { + type: 'pon'; + tiles: readonly [TileId, TileId, TileId]; + from: House; +} | { + type: 'cii'; + tiles: readonly [TileId, TileId, TileId]; + from: House; +} | { + type: 'ankan'; + tiles: readonly [TileId, TileId, TileId, TileId]; +} | { + type: 'minkan'; + tiles: readonly [TileId, TileId, TileId, TileId]; + from: House | null; // null で加槓 +}; + +export type PointFactor = { + isYakuman: false; + fan: number; +} | { + isYakuman: true; + value: number; +}; + +export const CALL_HURO_TYPES = ['pon', 'cii', 'minkan'] as const; + +export const NEXT_TILE_FOR_DORA_MAP: Record = { + m1: 'm2', + m2: 'm3', + m3: 'm4', + m4: 'm5', + m5: 'm6', + m6: 'm7', + m7: 'm8', + m8: 'm9', + m9: 'm1', + p1: 'p2', + p2: 'p3', + p3: 'p4', + p4: 'p5', + p5: 'p6', + p6: 'p7', + p7: 'p8', + p8: 'p9', + p9: 'p1', + s1: 's2', + s2: 's3', + s3: 's4', + s4: 's5', + s5: 's6', + s6: 's7', + s7: 's8', + s8: 's9', + s9: 's1', + e: 's', + s: 'w', + w: 'n', + n: 'e', + haku: 'hatsu', + hatsu: 'chun', + chun: 'haku', +}; + +export const NEXT_TILE_FOR_SHUNTSU: Record = { + m1: 'm2', + m2: 'm3', + m3: 'm4', + m4: 'm5', + m5: 'm6', + m6: 'm7', + m7: 'm8', + m8: 'm9', + m9: null, + p1: 'p2', + p2: 'p3', + p3: 'p4', + p4: 'p5', + p5: 'p6', + p6: 'p7', + p7: 'p8', + p8: 'p9', + p9: null, + s1: 's2', + s2: 's3', + s3: 's4', + s4: 's5', + s5: 's6', + s6: 's7', + s7: 's8', + s8: 's9', + s9: null, + e: null, + s: null, + w: null, + n: null, + haku: null, + hatsu: null, + chun: null, +}; + +export const PREV_TILE_FOR_SHUNTSU: Record = { + m1: null, + m2: 'm1', + m3: 'm2', + m4: 'm3', + m5: 'm4', + m6: 'm5', + m7: 'm6', + m8: 'm7', + m9: 'm8', + p1: null, + p2: 'p1', + p3: 'p2', + p4: 'p3', + p5: 'p4', + p6: 'p5', + p7: 'p6', + p8: 'p7', + p9: 'p8', + s1: null, + s2: 's1', + s3: 's2', + s4: 's3', + s5: 's4', + s6: 's5', + s7: 's6', + s8: 's7', + s9: 's8', + e: null, + s: null, + w: null, + n: null, + haku: null, + hatsu: null, + chun: null, +}; + +export const TILE_NUMBER_MAP: Record = { + m1: 1, + m2: 2, + m3: 3, + m4: 4, + m5: 5, + m6: 6, + m7: 7, + m8: 8, + m9: 9, + p1: 1, + p2: 2, + p3: 3, + p4: 4, + p5: 5, + p6: 6, + p7: 7, + p8: 8, + p9: 9, + s1: 1, + s2: 2, + s3: 3, + s4: 4, + s5: 5, + s6: 6, + s7: 7, + s8: 8, + s9: 9, + e: null, + s: null, + w: null, + n: null, + haku: null, + hatsu: null, + chun: null, +}; + +export const MANZU_TILES = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9'] as const satisfies TileType[]; +export const PINZU_TILES = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9'] as const satisfies TileType[]; +export const SOUZU_TILES = ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9'] as const satisfies TileType[]; +export const CHAR_TILES = ['e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[]; +export const YAOCHU_TILES = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun'] as const satisfies TileType[]; +export const TERMINAL_TILES = ['m1', 'm9', 'p1', 'p9', 's1', 's9'] as const satisfies TileType[]; +const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; + +export function includes>(array: A, searchElement: unknown): searchElement is A[number] { + return array.includes(searchElement); +} + +export function isManzu(tile: TileType): tile is typeof MANZU_TILES[number] { + return includes(MANZU_TILES, tile); +} + +export function isPinzu(tile: TileType): tile is typeof PINZU_TILES[number] { + return includes(PINZU_TILES, tile); +} + +export function isSouzu(tile: TileType): tile is typeof SOUZU_TILES[number] { + return includes(SOUZU_TILES, tile); +} + +export function isSameNumberTile(a: TileType, b: TileType): boolean { + const aNumber = TILE_NUMBER_MAP[a]; + const bNumber = TILE_NUMBER_MAP[b]; + if (aNumber == null || bNumber == null) return false; + return aNumber === bNumber; +} + +export function fanToPoint(fan: number, isParent: boolean): number { + let point; + + if (fan >= 13) { + point = 32000; + } else if (fan >= 11) { + point = 24000; + } else if (fan >= 8) { + point = 16000; + } else if (fan >= 6) { + point = 12000; + } else if (fan >= 4) { + point = 8000; + } else if (fan >= 3) { + point = 4000; + } else if (fan >= 2) { + point = 2000; + } else { + point = 1000; + } + + if (isParent) { + point *= 1.5; + } + + return point; +} + +export function calcPoint(factor: PointFactor, isParent: boolean): number { + if (factor.isYakuman) { + return 32000 * factor.value * (isParent ? 1.5 : 1); + } else { + return fanToPoint(factor.fan, isParent); + } +} + +export function calcOwnedDoraCount(handTiles: TileType[], huros: Huro[], doras: TileType[]): number { + let count = 0; + for (const t of handTiles) { + if (doras.includes(t)) count++; + } + for (const huro of huros) { + if (huro.type === 'pon' && includes(doras, huro.tiles[0])) count += 3; + if (huro.type === 'cii') count += huro.tiles.filter(t => includes(doras, t)).length; + if (huro.type === 'minkan' && includes(doras, huro.tiles[0])) count += 4; + if (huro.type === 'ankan' && includes(doras, huro.tiles[0])) count += 4; + } + return count; +} + +export function calcRedDoraCount(handTiles: TileId[], huros: Huro[]): number { + let count = 0; + for (const t of handTiles) { + if (findTileByIdOrFail(t).red) count++; + } + for (const huro of huros) { + for (const t of huro.tiles) { + if (findTileByIdOrFail(t).red) count++; + } + } + return count; +} + +export function calcTsumoHoraPointDeltas(house: House, fansOrFactor: number | PointFactor): Record { + const isParent = house === 'e'; + + const deltas: Record = { + e: 0, + s: 0, + w: 0, + n: 0, + }; + + const point = typeof fansOrFactor === 'number' ? fanToPoint(fansOrFactor, isParent) : calcPoint(fansOrFactor, isParent); + deltas[house] = point; + if (isParent) { + const childPoint = Math.ceil(point / 3); + deltas.s = -childPoint; + deltas.w = -childPoint; + deltas.n = -childPoint; + } else { + const parentPoint = Math.ceil(point / 2); + deltas.e = -parentPoint; + const otherPoint = Math.ceil(point / 4); + if (house === 's') { + deltas.w = -otherPoint; + deltas.n = -otherPoint; + } else if (house === 'w') { + deltas.s = -otherPoint; + deltas.n = -otherPoint; + } else if (house === 'n') { + deltas.s = -otherPoint; + deltas.w = -otherPoint; + } + } + + return deltas; +} + +export function isTile(tile: string): tile is TileType { + return TILE_TYPES.includes(tile as TileType); +} + +export function sortTiles(tiles: TileId[]): TileId[] { + return tiles.toSorted((a, b) => { + return a - b; + }); +} + +export function sortTileTypes(tiles: TileType[]): TileType[] { + return tiles.toSorted((a, b) => { + const aIndex = TILE_TYPES.indexOf(a); + const bIndex = TILE_TYPES.indexOf(b); + return aIndex - bIndex; + }); +} + +export function nextHouse(house: House): House { + switch (house) { + case 'e': return 's'; + case 's': return 'w'; + case 'w': return 'n'; + case 'n': return 'e'; + default: throw new Error(`unrecognized house: ${house}`); + } +} + +export function prevHouse(house: House): House { + switch (house) { + case 'e': return 'n'; + case 's': return 'e'; + case 'w': return 's'; + case 'n': return 'w'; + default: throw new Error(`unrecognized house: ${house}`); + } +} + +export type FourMentsuOneJyantou = { + head: TileType; + mentsus: [TileType, TileType, TileType][]; +}; + +export function isShuntu(tiles: [TileType, TileType, TileType]): boolean { + return tiles[0] !== tiles[1]; +} + +export function isKotsu(tiles: [TileType, TileType, TileType]): boolean { + return tiles[0] === tiles[1]; +} + +export function mentsuEquals(tiles1: [TileType, TileType, TileType], tiles2: [TileType, TileType, TileType]): boolean { + return tiles1[0] === tiles2[0] && tiles1[1] === tiles2[1] && tiles1[2] === tiles2[2]; +} + +export const SHUNTU_PATTERNS: [TileType, TileType, TileType][] = [ + ['m1', 'm2', 'm3'], + ['m2', 'm3', 'm4'], + ['m3', 'm4', 'm5'], + ['m4', 'm5', 'm6'], + ['m5', 'm6', 'm7'], + ['m6', 'm7', 'm8'], + ['m7', 'm8', 'm9'], + ['p1', 'p2', 'p3'], + ['p2', 'p3', 'p4'], + ['p3', 'p4', 'p5'], + ['p4', 'p5', 'p6'], + ['p5', 'p6', 'p7'], + ['p6', 'p7', 'p8'], + ['p7', 'p8', 'p9'], + ['s1', 's2', 's3'], + ['s2', 's3', 's4'], + ['s3', 's4', 's5'], + ['s4', 's5', 's6'], + ['s5', 's6', 's7'], + ['s6', 's7', 's8'], + ['s7', 's8', 's9'], +]; + +function extractShuntsus(tiles: TileType[]): [TileType, TileType, TileType][] { + const tempTiles = [...tiles]; + + tempTiles.sort((a, b) => { + const aIndex = TILE_TYPES.indexOf(a); + const bIndex = TILE_TYPES.indexOf(b); + return aIndex - bIndex; + }); + + const shuntsus: [TileType, TileType, TileType][] = []; + while (tempTiles.length > 0) { + let isShuntu = false; + for (const shuntuPattern of SHUNTU_PATTERNS) { + if ( + tempTiles[0] === shuntuPattern[0] && + tempTiles.includes(shuntuPattern[1]) && + tempTiles.includes(shuntuPattern[2]) + ) { + shuntsus.push(shuntuPattern); + tempTiles.splice(0, 1); + tempTiles.splice(tempTiles.indexOf(shuntuPattern[1]), 1); + tempTiles.splice(tempTiles.indexOf(shuntuPattern[2]), 1); + isShuntu = true; + break; + } + } + + if (!isShuntu) tempTiles.splice(0, 1); + } + + return shuntsus; +} + +export function analyzeFourMentsuOneJyantou(handTiles: TileType[], all = true): FourMentsuOneJyantou[] { + const horaSets: FourMentsuOneJyantou[] = []; + + const headSet: TileType[] = []; + const countMap = new Map(); + for (const tile of handTiles) { + const count = (countMap.get(tile) ?? 0) + 1; + countMap.set(tile, count); + if (count === 2) { + headSet.push(tile); + } + } + + for (const head of headSet) { + const tempHandTiles = [...handTiles]; + tempHandTiles.splice(tempHandTiles.indexOf(head), 1); + tempHandTiles.splice(tempHandTiles.indexOf(head), 1); + + const kotsuTileSet: TileType[] = []; // インデックスアクセスしたいため配列だが実態はSet + for (const [t, c] of countMap.entries()) { + if (t === head) continue; // 同じ牌種は4枚しかないので、頭と同じ牌種は刻子になりえない + if (c >= 3) { + kotsuTileSet.push(t); + } + } + + let kotsuPatterns: TileType[][]; + if (kotsuTileSet.length === 0) { + kotsuPatterns = [ + [], + ]; + } else if (kotsuTileSet.length === 1) { + kotsuPatterns = [ + [], + [kotsuTileSet[0]], + ]; + } else if (kotsuTileSet.length === 2) { + kotsuPatterns = [ + [], + [kotsuTileSet[0]], + [kotsuTileSet[1]], + [kotsuTileSet[0], kotsuTileSet[1]], + ]; + } else if (kotsuTileSet.length === 3) { + kotsuPatterns = [ + [], + [kotsuTileSet[0]], + [kotsuTileSet[1]], + [kotsuTileSet[2]], + [kotsuTileSet[0], kotsuTileSet[1]], + [kotsuTileSet[0], kotsuTileSet[2]], + [kotsuTileSet[1], kotsuTileSet[2]], + [kotsuTileSet[0], kotsuTileSet[1], kotsuTileSet[2]], + ]; + } else if (kotsuTileSet.length === 4) { + kotsuPatterns = [ + [], + [kotsuTileSet[0]], + [kotsuTileSet[1]], + [kotsuTileSet[2]], + [kotsuTileSet[3]], + [kotsuTileSet[0], kotsuTileSet[1]], + [kotsuTileSet[0], kotsuTileSet[2]], + [kotsuTileSet[0], kotsuTileSet[3]], + [kotsuTileSet[1], kotsuTileSet[2]], + [kotsuTileSet[1], kotsuTileSet[3]], + [kotsuTileSet[2], kotsuTileSet[3]], + [kotsuTileSet[0], kotsuTileSet[1], kotsuTileSet[2]], + [kotsuTileSet[0], kotsuTileSet[1], kotsuTileSet[3]], + [kotsuTileSet[0], kotsuTileSet[2], kotsuTileSet[3]], + [kotsuTileSet[1], kotsuTileSet[2], kotsuTileSet[3]], + [kotsuTileSet[0], kotsuTileSet[1], kotsuTileSet[2], kotsuTileSet[3]], + ]; + } else { + throw new Error('arienai'); + } + + for (const kotsuPattern of kotsuPatterns) { + const tempHandTilesWithoutKotsu = [...tempHandTiles]; + for (const kotsuTile of kotsuPattern) { + tempHandTilesWithoutKotsu.splice(tempHandTilesWithoutKotsu.indexOf(kotsuTile), 1); + tempHandTilesWithoutKotsu.splice(tempHandTilesWithoutKotsu.indexOf(kotsuTile), 1); + tempHandTilesWithoutKotsu.splice(tempHandTilesWithoutKotsu.indexOf(kotsuTile), 1); + } + + const shuntsus = extractShuntsus(tempHandTilesWithoutKotsu); + + if (shuntsus.length * 3 === tempHandTilesWithoutKotsu.length) { // アガリ形 + horaSets.push({ + head, + mentsus: [...kotsuPattern.map(t => [t, t, t] as [TileType, TileType, TileType]), ...shuntsus], + }); + + if (!all) return horaSets; + } + } + } + + return horaSets; +} + +export function nextTileForDora(tile: TileType): TileType { + return NEXT_TILE_FOR_DORA_MAP[tile]; +} + +export function getAvailableCiiPatterns(handTiles: TileType[], targetTile: TileType): [TileType, TileType, TileType][] { + const patterns: [TileType, TileType, TileType][] = []; + const prev1 = PREV_TILE_FOR_SHUNTSU[targetTile]; + const prev2 = prev1 != null ? PREV_TILE_FOR_SHUNTSU[prev1] : null; + const next1 = NEXT_TILE_FOR_SHUNTSU[targetTile]; + const next2 = next1 != null ? NEXT_TILE_FOR_SHUNTSU[next1] : null; + if (prev2 != null && prev1 != null) { + if (handTiles.includes(prev2) && handTiles.includes(prev1)) { + patterns.push([prev2, prev1, targetTile]); + } + } + if (prev1 != null && next1 != null) { + if (handTiles.includes(prev1) && handTiles.includes(next1)) { + patterns.push([prev1, targetTile, next1]); + } + } + if (next1 != null && next2 != null) { + if (handTiles.includes(next1) && handTiles.includes(next2)) { + patterns.push([targetTile, next1, next2]); + } + } + return patterns; +} + +function isKokushiPattern(handTiles: TileType[]): boolean { + return KOKUSHI_TILES.every(t => handTiles.includes(t)); +} + +function isChitoitsuPattern(handTiles: TileType[]): boolean { + if (handTiles.length !== 14) return false; + const countMap = new Map(); + for (const tile of handTiles) { + const count = (countMap.get(tile) ?? 0) + 1; + countMap.set(tile, count); + } + return Array.from(countMap.values()).every(c => c === 2); +} + +export function isAgarikei(handTiles: TileType[]): boolean { + if (isKokushiPattern(handTiles)) return true; + if (isChitoitsuPattern(handTiles)) return true; + + const agarikeis = analyzeFourMentsuOneJyantou(handTiles, false); + return agarikeis.length > 0; +} + +export function isTenpai(handTiles: TileType[]): boolean { + return TILE_TYPES.some(tile => { + const tempHandTiles = [...handTiles, tile]; + return isAgarikei(tempHandTiles); + }); +} + +export function getTilesForRiichi(handTiles: TileType[]): TileType[] { + return handTiles.filter(tile => { + const tempHandTiles = [...handTiles]; + tempHandTiles.splice(tempHandTiles.indexOf(tile), 1); + return isTenpai(tempHandTiles); + }); +} diff --git a/packages/misskey-mahjong/src/common.yaku.ts b/packages/misskey-mahjong/src/common.yaku.ts new file mode 100644 index 0000000000..dd551b0bea --- /dev/null +++ b/packages/misskey-mahjong/src/common.yaku.ts @@ -0,0 +1,1113 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { CALL_HURO_TYPES, CHAR_TILES, FourMentsuOneJyantou, House, MANZU_TILES, PINZU_TILES, SOUZU_TILES, TileType, YAOCHU_TILES, TILE_TYPES, analyzeFourMentsuOneJyantou, isShuntu, isManzu, isPinzu, isSameNumberTile, isSouzu, isKotsu, includes, TERMINAL_TILES, mentsuEquals, Huro, TILE_ID_MAP } from './common.js'; +import { calcWaitPatterns, isRyanmen, isToitsu, FourMentsuOneJyantouWithWait } from './common.fu.js'; + +const RYUISO_TILES: TileType[] = ['s2', 's3', 's4', 's6', 's8', 'hatsu']; +const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; + +export const NORMAL_YAKU_NAMES = [ + 'riichi', + 'ippatsu', + 'tsumo', + 'tanyao', + 'pinfu', + 'iipeko', + 'field-wind-e', + 'field-wind-s', + 'field-wind-w', + 'field-wind-n', + 'seat-wind-e', + 'seat-wind-s', + 'seat-wind-w', + 'seat-wind-n', + 'white', + 'green', + 'red', + 'rinshan', + 'chankan', + 'haitei', + 'hotei', + 'sanshoku-dojun', + 'sanshoku-doko', + 'ittsu', + 'chanta', + 'chitoitsu', + 'toitoi', + 'sananko', + 'honroto', + 'sankantsu', + 'shosangen', + 'double-riichi', + 'honitsu', + 'junchan', + 'ryampeko', + 'chinitsu', + 'dora', + 'red-dora', +] as const; + +export const YAKUMAN_NAMES = [ + 'kokushi', + 'kokushi-13', + 'suanko', + 'suanko-tanki', + 'daisangen', + 'tsuiso', + 'shosushi', + 'daisushi', + 'ryuiso', + 'chinroto', + 'sukantsu', + 'churen', + 'churen-9', + 'tenho', + 'chiho', +] as const; + +type NormalYakuName = typeof NORMAL_YAKU_NAMES[number]; + +type YakumanName = typeof YAKUMAN_NAMES[number]; + +export type YakuName = NormalYakuName | YakumanName; + +type Pon = { + type: 'pon'; + tile: TileType; +}; + +type Cii = { + type: 'cii'; + tiles: [TileType, TileType, TileType]; +}; + +type Ankan = { + type: 'ankan'; + tile: TileType; +}; + +type Minkan = { + type: 'minkan'; + tile: TileType; +}; + +export type HuroForCalcYaku = Pon | Cii | Ankan | Minkan; + +export type EnvForCalcYaku = { + /** + * 和了る人の手牌(副露牌は含まず、ツモ、ロン牌は含む) + */ + handTiles: TileType[]; + + /** + * 河 + */ + hoTiles?: TileType[]; + + /** + * 副露 + */ + huros: HuroForCalcYaku[]; + + /** + * 場風 + */ + fieldWind?: House; + + /** + * 自風 + */ + seatWind?: House; + + /** + * 局が始まってから誰の副露もない一巡目かどうか + */ + firstTurn?: boolean; + + /** + * リーチしたかどうか + */ + riichi?: boolean; + + /** + * 誰の副露もない一巡目でリーチしたかどうか + */ + doubleRiichi?: boolean; + + /** + * リーチしてから誰の副露もない一巡目以内かどうか + */ + ippatsu?: boolean; +} & ({ + tsumoTile: TileType; + ronTile?: null; + + /** + * 嶺上牌のツモか + */ + rinshan?: boolean; + + /** + * 海底牌か + */ + haitei?: boolean; +} | { + tsumoTile?: null; + ronTile: TileType; + + /** + * 河底牌か + */ + hotei?: boolean; +}); + +interface YakuDataBase { + name: YakuName; + upper?: YakuName | null; + fan?: number | null; + isYakuman?: boolean; +} + +interface NormalYakuData extends YakuDataBase { + name: NormalYakuName; + fan: number; + isYakuman?: false; + kuisagari?: boolean; +} + +interface YakumanData extends YakuDataBase { + name: YakumanName; + isYakuman: true; + isDoubleYakuman?: boolean; +} + +export type YakuData = Required | Required; + +abstract class YakuSetBase { + public readonly isYakuman: IsYakuman; + + public readonly yakus: YakuData[]; + + public get yakuNames(): YakuName[] { + return this.yakus.map(yaku => yaku.name); + } + + constructor(isYakuman: IsYakuman, yakus: YakuData[]) { + this.isYakuman = isYakuman; + this.yakus = yakus; + } +} + +class NormalYakuSet extends YakuSetBase { + public readonly isMenzen: boolean; + + public readonly fan: number; + + constructor(isMenzen: boolean, yakus: Required[]) { + super(false, yakus); + this.isMenzen = isMenzen; + this.fan = yakus.reduce((fan, yaku) => fan + (!isMenzen && yaku.kuisagari ? yaku.fan - 1 : yaku.fan), 0); + } +} + +class YakumanSet extends YakuSetBase { + /** + * 何倍役満か + */ + public readonly value: number; + + constructor(yakus: Required[]) { + super(true, yakus); + this.value = yakus.reduce((value, yaku) => value + (yaku.isDoubleYakuman ? 2 : 1), 0); + } +} + +export type YakuSet = NormalYakuSet | YakumanSet; + +type YakuDefinitionBase = { + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => boolean; +}; + +type NormalYakuDefinition = YakuDefinitionBase & NormalYakuData; + +type YakumanDefinition = YakuDefinitionBase & YakumanData; + +function countTiles(tiles: TileType[], target: TileType): number { + return tiles.filter(t => t === target).length; +} + +class Yakuhai implements NormalYakuDefinition { + readonly name: NormalYakuName; + + readonly fan = 1; + + readonly isYakuman = false; + + readonly tile: typeof CHAR_TILES[number]; + + constructor(name: NormalYakuName, house: typeof CHAR_TILES[number]) { + this.name = name; + this.tile = house; + } + + calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean { + if (fourMentsuOneJyantou == null) return false; + + return ( + (countTiles(state.handTiles, this.tile) >= 3) || + (state.huros.some(huro => + huro.type === 'pon' ? huro.tile === this.tile : + huro.type === 'ankan' ? huro.tile === this.tile : + huro.type === 'minkan' ? huro.tile === this.tile : + false)) + ); + } +} + +class FieldWind extends Yakuhai { + calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean { + return super.calc(state, fourMentsuOneJyantou) && state.fieldWind === this.tile; + } +} + +class SeatWind extends Yakuhai { + calc(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null): boolean { + return super.calc(state, fourMentsuOneJyantou) && state.seatWind === this.tile; + } +} + +/** + * 2つの同じ面子の組を数える (一盃口なら1、二盃口なら2) + */ +function countIndenticalMentsuPairs(mentsus: [TileType, TileType, TileType][]) { + let result = 0; + const singleMentsus: [TileType, TileType, TileType][] = []; + loop: for (const mentsu of mentsus) { + for (let i = 0; i < singleMentsus.length; i++) { + if (mentsuEquals(mentsu, singleMentsus[i])) { + result++; + singleMentsus.splice(i, 1); + continue loop; + } + } + singleMentsus.push(mentsu); + } + return result; +} + +/** + * 暗刻の数を数える (三暗刻なら3、四暗刻なら4) + */ +function countAnkos(state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait) { + const ankans = state.huros.filter(huro => huro.type === 'ankan').length; + const handKotsus = fourMentsuOneJyantou.mentsus.filter(mentsu => isKotsu(mentsu)).length; + + // ロンによりできた刻子は暗刻ではない + if (state.ronTile != null && fourMentsuOneJyantou.waitedFor === 'mentsu' && isToitsu(fourMentsuOneJyantou.waitedTaatsu)) { + return ankans + handKotsus - 1; + } + + return ankans + handKotsus; +} + +export const NORMAL_YAKU_DEFINITIONS: NormalYakuDefinition[] = [ + { + name: 'tsumo', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + // 門前じゃないとダメ + if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false; + + return state.tsumoTile != null; + }, + }, + { + name: 'riichi', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return !state.doubleRiichi && (state.riichi ?? false); + }, + }, + { + name: 'double-riichi', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku) => { + return state.doubleRiichi ?? false; + }, + }, + { + name: 'ippatsu', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return state.ippatsu ?? false; + }, + }, + { + name: 'rinshan', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return (state.tsumoTile != null && state.rinshan) ?? false; + }, + }, + { + name: 'haitei', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return (state.tsumoTile != null && state.haitei) ?? false; + }, + }, + { + name: 'hotei', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return (state.ronTile != null && state.hotei) ?? false; + }, + }, + new Yakuhai('red', 'chun'), + new Yakuhai('white', 'haku'), + new Yakuhai('green', 'hatsu'), + new FieldWind('field-wind-e', 'e'), + new FieldWind('field-wind-s', 's'), + new FieldWind('field-wind-w', 'w'), + new FieldWind('field-wind-n', 'n'), + new SeatWind('seat-wind-e', 'e'), + new SeatWind('seat-wind-s', 's'), + new SeatWind('seat-wind-w', 'w'), + new SeatWind('seat-wind-n', 'n'), + { + name: 'tanyao', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return ( + (!state.handTiles.some(t => includes(YAOCHU_TILES, t))) && + (state.huros.filter(huro => + huro.type === 'pon' ? includes(YAOCHU_TILES, huro.tile) : + huro.type === 'ankan' ? includes(YAOCHU_TILES, huro.tile) : + huro.type === 'minkan' ? includes(YAOCHU_TILES, huro.tile) : + huro.type === 'cii' ? huro.tiles.some(t2 => includes(YAOCHU_TILES, t2)) : + false).length === 0) + ); + }, + }, + { + name: 'pinfu', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false; + // 三元牌はダメ + if (state.handTiles.some(t => ['haku', 'hatsu', 'chun'].includes(t))) return false; + + // 両面待ちかどうか + if (!(fourMentsuOneJyantou != null && fourMentsuOneJyantou.waitedFor === 'mentsu' && isRyanmen(fourMentsuOneJyantou.waitedTaatsu))) return false; + + // 風牌判定(役牌でなければOK) + if (fourMentsuOneJyantou.head === state.seatWind) return false; + if (fourMentsuOneJyantou.head === state.fieldWind) return false; + + // 全て順子か? + if (fourMentsuOneJyantou.mentsus.some((mentsu) => mentsu[0] === mentsu[1])) return false; + + return true; + }, + }, + { + name: 'honitsu', + fan: 3, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length; + let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length; + let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length; + let charCount = tiles.filter(t => includes(CHAR_TILES, t)).length; + + for (const huro of state.huros) { + const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile]; + manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length; + pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length; + souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length; + charCount += huroTiles.filter(t => includes(CHAR_TILES, t)).length; + } + + if (manzuCount > 0 && pinzuCount > 0) return false; + if (manzuCount > 0 && souzuCount > 0) return false; + if (pinzuCount > 0 && souzuCount > 0) return false; + if (charCount === 0) return false; + + return true; + }, + }, + { + name: 'chinitsu', + fan: 6, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length; + let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length; + let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length; + let charCount = tiles.filter(t => includes(CHAR_TILES, t)).length; + + for (const huro of state.huros) { + const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile]; + manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length; + pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length; + souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length; + charCount += huroTiles.filter(t => includes(CHAR_TILES, t)).length; + } + + if (charCount > 0) return false; + if (manzuCount > 0 && pinzuCount > 0) return false; + if (manzuCount > 0 && souzuCount > 0) return false; + if (pinzuCount > 0 && souzuCount > 0) return false; + + return true; + }, + }, + { + name: 'iipeko', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false; + + // 同じ順子が2つあるか? + return countIndenticalMentsuPairs(fourMentsuOneJyantou.mentsus) === 1; + }, + }, + { + name: 'ryampeko', + fan: 3, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false; + + // 2つの同じ順子が2組あるか? + return countIndenticalMentsuPairs(fourMentsuOneJyantou.mentsus) === 2; + }, + }, + { + name: 'toitoi', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + if (state.huros.length > 0) { + if (state.huros.some(huro => huro.type === 'cii')) return false; + } + + // 全て刻子か? + if (!fourMentsuOneJyantou.mentsus.every((mentsu) => mentsu[0] === mentsu[1])) return false; + + return true; + }, + }, + { + name: 'sananko', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => { + return fourMentsuOneJyantou != null && countAnkos(state, fourMentsuOneJyantou) === 3; + }, + }, + { + name: 'honroto', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku) => { + return state.huros.every(huro => huro.type !== 'cii' && includes(YAOCHU_TILES, huro.tile)) && + state.handTiles.every(tile => includes(YAOCHU_TILES, tile)); + }, + }, + { + name: 'sankantsu', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return fourMentsuOneJyantou != null && + state.huros.filter(huro => huro.type === 'ankan' || huro.type === 'minkan').length === 3; + }, + }, + { + name: 'sanshoku-dojun', + fan: 2, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const shuntsus = fourMentsuOneJyantou.mentsus.filter(tiles => isShuntu(tiles)); + + for (const shuntsu of shuntsus) { + if (isManzu(shuntsu[0])) { + if (shuntsus.some(tiles => isPinzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + if (shuntsus.some(tiles => isSouzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + return true; + } + } + } else if (isPinzu(shuntsu[0])) { + if (shuntsus.some(tiles => isManzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + if (shuntsus.some(tiles => isSouzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + return true; + } + } + } else if (isSouzu(shuntsu[0])) { + if (shuntsus.some(tiles => isManzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + if (shuntsus.some(tiles => isPinzu(tiles[0]) && isSameNumberTile(tiles[0], shuntsu[0]) && isSameNumberTile(tiles[1], shuntsu[1]) && isSameNumberTile(tiles[2], shuntsu[2]))) { + return true; + } + } + } + } + + return false; + }, + }, + { + name: 'sanshoku-doko', + fan: 2, + isYakuman: false, + kuisagari: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const kotsus = fourMentsuOneJyantou.mentsus.filter(tiles => isKotsu(tiles)); + + for (const kotsu of kotsus) { + if (isManzu(kotsu[0])) { + if (kotsus.some(tiles => isPinzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + if (kotsus.some(tiles => isSouzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + return true; + } + } + } else if (isPinzu(kotsu[0])) { + if (kotsus.some(tiles => isManzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + if (kotsus.some(tiles => isSouzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + return true; + } + } + } else if (isSouzu(kotsu[0])) { + if (kotsus.some(tiles => isManzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + if (kotsus.some(tiles => isPinzu(tiles[0]) && isSameNumberTile(tiles[0], kotsu[0]))) { + return true; + } + } + } + } + + return false; + }, + }, + { + name: 'ittsu', + fan: 2, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const shuntsus = fourMentsuOneJyantou.mentsus.filter(tiles => isShuntu(tiles)); + shuntsus.push(...state.huros.filter((huro): huro is Cii => huro.type === 'cii').map(huro => huro.tiles)); + + if (shuntsus.some(tiles => tiles[0] === 'm1' && tiles[1] === 'm2' && tiles[2] === 'm3')) { + if (shuntsus.some(tiles => tiles[0] === 'm4' && tiles[1] === 'm5' && tiles[2] === 'm6')) { + if (shuntsus.some(tiles => tiles[0] === 'm7' && tiles[1] === 'm8' && tiles[2] === 'm9')) { + return true; + } + } + } + if (shuntsus.some(tiles => tiles[0] === 'p1' && tiles[1] === 'p2' && tiles[2] === 'p3')) { + if (shuntsus.some(tiles => tiles[0] === 'p4' && tiles[1] === 'p5' && tiles[2] === 'p6')) { + if (shuntsus.some(tiles => tiles[0] === 'p7' && tiles[1] === 'p8' && tiles[2] === 'p9')) { + return true; + } + } + } + if (shuntsus.some(tiles => tiles[0] === 's1' && tiles[1] === 's2' && tiles[2] === 's3')) { + if (shuntsus.some(tiles => tiles[0] === 's4' && tiles[1] === 's5' && tiles[2] === 's6')) { + if (shuntsus.some(tiles => tiles[0] === 's7' && tiles[1] === 's8' && tiles[2] === 's9')) { + return true; + } + } + } + + return false; + }, + }, + { + name: 'chanta', + fan: 2, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const { head, mentsus } = fourMentsuOneJyantou; + const { huros } = state; + + // 雀頭は幺九牌じゃないとダメ + if (!includes(YAOCHU_TILES, head)) return false; + + // 順子は1つ以上じゃないとダメ + if (!mentsus.some(mentsu => isShuntu(mentsu))) return false; + + // いずれかの雀頭か面子に字牌を含まないとダメ + if (!(includes(CHAR_TILES, head) || + mentsus.some(mentsu => includes(CHAR_TILES, mentsu[0])) || + huros.some(huro => huro.type !== 'cii' && includes(CHAR_TILES, huro.tile)))) return false; + + // 全ての面子に幺九牌が含まれる + return (mentsus.every(mentsu => mentsu.some(tile => includes(YAOCHU_TILES, tile))) && + huros.every(huro => huro.type === 'cii' ? + huro.tiles.some(tile => includes(YAOCHU_TILES, tile)) : + includes(YAOCHU_TILES, huro.tile))); + }, + }, + { + name: 'junchan', + fan: 3, + isYakuman: false, + kuisagari: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const { head, mentsus } = fourMentsuOneJyantou; + const { huros } = state; + + // 雀頭は老頭牌じゃないとダメ + if (!includes(TERMINAL_TILES, head)) return false; + + // 順子は1つ以上じゃないとダメ + if (!mentsus.some(mentsu => isShuntu(mentsu))) return false; + + // 全ての面子に老頭牌が含まれる + return (mentsus.every(mentsu => mentsu.some(tile => includes(TERMINAL_TILES, tile))) && + huros.every(huro => huro.type === 'cii' ? + huro.tiles.some(tile => includes(TERMINAL_TILES, tile)) : + includes(TERMINAL_TILES, huro.tile))); + }, + }, + { + name: 'chitoitsu', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou != null) return false; + if (state.huros.length > 0) return false; + const countMap = new Map(); + for (const tile of state.handTiles) { + const count = (countMap.get(tile) ?? 0) + 1; + countMap.set(tile, count); + } + return Array.from(countMap.values()).every(c => c === 2); + }, + }, + { + name: 'shosangen', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const kotsuTiles = fourMentsuOneJyantou.mentsus.filter(tiles => isKotsu(tiles)).map(tiles => tiles[0]); + + for (const huro of state.huros) { + if (huro.type === 'cii') { + // nop + } else if (huro.type === 'pon') { + kotsuTiles.push(huro.tile); + } else { + kotsuTiles.push(huro.tile); + } + } + + switch (fourMentsuOneJyantou.head) { + case 'haku': return kotsuTiles.includes('hatsu') && kotsuTiles.includes('chun'); + case 'hatsu': return kotsuTiles.includes('haku') && kotsuTiles.includes('chun'); + case 'chun': return kotsuTiles.includes('haku') && kotsuTiles.includes('hatsu'); + } + + return false; + }, + }, +]; + +export const YAKUMAN_DEFINITIONS: YakumanDefinition[] = [ + { + name: 'suanko-tanki', + isYakuman: true, + isDoubleYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => { + return fourMentsuOneJyantou != null && fourMentsuOneJyantou.waitedFor === 'head' && countAnkos(state, fourMentsuOneJyantou) === 4; + }, + }, + { + name: 'suanko', + isYakuman: true, + upper: 'suanko-tanki', + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantouWithWait | null) => { + return fourMentsuOneJyantou != null && countAnkos(state, fourMentsuOneJyantou) === 4; + }, + }, + { + name: 'daisangen', + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const kotsuTiles = fourMentsuOneJyantou.mentsus.filter(tiles => isKotsu(tiles)).map(tiles => tiles[0]); + + for (const huro of state.huros) { + if (huro.type === 'cii') { + // nop + } else if (huro.type === 'pon') { + kotsuTiles.push(huro.tile); + } else { + kotsuTiles.push(huro.tile); + } + } + + return kotsuTiles.includes('haku') && kotsuTiles.includes('hatsu') && kotsuTiles.includes('chun'); + }, + }, + { + name: 'shosushi', + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + let all = [...state.handTiles]; + for (const huro of state.huros) { + if (huro.type === 'cii') { + all = [...all, ...huro.tiles]; + } else if (huro.type === 'pon') { + all = [...all, huro.tile, huro.tile, huro.tile]; + } else { + all = [...all, huro.tile, huro.tile, huro.tile, huro.tile]; + } + } + + switch (fourMentsuOneJyantou.head) { + case 'e': return (countTiles(all, 's') === 3) && (countTiles(all, 'w') === 3) && (countTiles(all, 'n') === 3); + case 's': return (countTiles(all, 'e') === 3) && (countTiles(all, 'w') === 3) && (countTiles(all, 'n') === 3); + case 'w': return (countTiles(all, 'e') === 3) && (countTiles(all, 's') === 3) && (countTiles(all, 'n') === 3); + case 'n': return (countTiles(all, 'e') === 3) && (countTiles(all, 's') === 3) && (countTiles(all, 'w') === 3); + } + + return false; + }, + }, + { + name: 'daisushi', + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const kotsuTiles = fourMentsuOneJyantou.mentsus.filter(tiles => isKotsu(tiles)).map(tiles => tiles[0]); + + for (const huro of state.huros) { + if (huro.type === 'cii') { + // nop + } else if (huro.type === 'pon') { + kotsuTiles.push(huro.tile); + } else { + kotsuTiles.push(huro.tile); + } + } + + return kotsuTiles.includes('e') && kotsuTiles.includes('s') && kotsuTiles.includes('w') && kotsuTiles.includes('n'); + }, + }, + { + name: 'tsuiso', + isYakuman: true, + calc: (state: EnvForCalcYaku) => { + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => includes(MANZU_TILES, t)).length; + let pinzuCount = tiles.filter(t => includes(PINZU_TILES, t)).length; + let souzuCount = tiles.filter(t => includes(SOUZU_TILES, t)).length; + + for (const huro of state.huros) { + const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile]; + manzuCount += huroTiles.filter(t => includes(MANZU_TILES, t)).length; + pinzuCount += huroTiles.filter(t => includes(PINZU_TILES, t)).length; + souzuCount += huroTiles.filter(t => includes(SOUZU_TILES, t)).length; + } + + if (manzuCount > 0 || pinzuCount > 0 || souzuCount > 0) return false; + + return true; + }, + }, + { + name: 'ryuiso', + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + if (state.handTiles.some(t => !RYUISO_TILES.includes(t))) return false; + + for (const huro of state.huros) { + const huroTiles = huro.type === 'cii' ? huro.tiles : huro.type === 'pon' ? [huro.tile, huro.tile, huro.tile] : [huro.tile, huro.tile, huro.tile, huro.tile]; + if (huroTiles.some(t => !RYUISO_TILES.includes(t))) return false; + } + + return true; + }, + }, + { + name: 'chinroto', + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return fourMentsuOneJyantou != null && + state.huros.every(huro => huro.type !== 'cii' && includes(TERMINAL_TILES, huro.tile)) && + state.handTiles.every(tile => includes(TERMINAL_TILES, tile)); + }, + }, + { + name: 'sukantsu', + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return fourMentsuOneJyantou != null && + state.huros.filter(huro => huro.type === 'ankan' || huro.type === 'minkan').length === 4; + }, + }, + { + name: 'churen-9', + isYakuman: true, + isDoubleYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false; + + const agariTile = state.tsumoTile ?? state.ronTile; + if (agariTile == null) { + return false; + } + const tempaiTiles = [...state.handTiles]; + tempaiTiles.splice(state.handTiles.indexOf(agariTile), 1); + + if (isManzu(agariTile)) { + if ((countTiles(tempaiTiles, 'm1') === 3) && (countTiles(tempaiTiles, 'm9') === 3)) { + if (tempaiTiles.includes('m2') && tempaiTiles.includes('m3') && tempaiTiles.includes('m4') && tempaiTiles.includes('m5') && tempaiTiles.includes('m6') && tempaiTiles.includes('m7') && tempaiTiles.includes('m8')) { + return true; + } + } + } else if (isPinzu(agariTile)) { + if ((countTiles(tempaiTiles, 'p1') === 3) && (countTiles(tempaiTiles, 'p9') === 3)) { + if (tempaiTiles.includes('p2') && tempaiTiles.includes('p3') && tempaiTiles.includes('p4') && tempaiTiles.includes('p5') && tempaiTiles.includes('p6') && tempaiTiles.includes('p7') && tempaiTiles.includes('p8')) { + return true; + } + } + } else if (isSouzu(agariTile)) { + if ((countTiles(tempaiTiles, 's1') === 3) && (countTiles(tempaiTiles, 's9') === 3)) { + if (tempaiTiles.includes('s2') && tempaiTiles.includes('s3') && tempaiTiles.includes('s4') && tempaiTiles.includes('s5') && tempaiTiles.includes('s6') && tempaiTiles.includes('s7') && tempaiTiles.includes('s8')) { + return true; + } + } + } + + return false; + }, + }, + { + name: 'churen', + upper: 'churen-9', + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type))) return false; + + if (isManzu(state.handTiles[0])) { + if ((countTiles(state.handTiles, 'm1') === 3) && (countTiles(state.handTiles, 'm9') === 3)) { + if (state.handTiles.includes('m2') && state.handTiles.includes('m3') && state.handTiles.includes('m4') && state.handTiles.includes('m5') && state.handTiles.includes('m6') && state.handTiles.includes('m7') && state.handTiles.includes('m8')) { + return true; + } + } + } else if (isPinzu(state.handTiles[0])) { + if ((countTiles(state.handTiles, 'p1') === 3) && (countTiles(state.handTiles, 'p9') === 3)) { + if (state.handTiles.includes('p2') && state.handTiles.includes('p3') && state.handTiles.includes('p4') && state.handTiles.includes('p5') && state.handTiles.includes('p6') && state.handTiles.includes('p7') && state.handTiles.includes('p8')) { + return true; + } + } + } else if (isSouzu(state.handTiles[0])) { + if ((countTiles(state.handTiles, 's1') === 3) && (countTiles(state.handTiles, 's9') === 3)) { + if (state.handTiles.includes('s2') && state.handTiles.includes('s3') && state.handTiles.includes('s4') && state.handTiles.includes('s5') && state.handTiles.includes('s6') && state.handTiles.includes('s7') && state.handTiles.includes('s8')) { + return true; + } + } + } + + return false; + }, + }, + { + name: 'kokushi-13', + isYakuman: true, + isDoubleYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + const agariTile = state.tsumoTile ?? state.ronTile; + return KOKUSHI_TILES.every(t => state.handTiles.includes(t)) && countTiles(state.handTiles, agariTile) === 2; + }, + }, + { + name: 'kokushi', + isYakuman: true, + upper: 'kokushi-13', + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return KOKUSHI_TILES.every(t => state.handTiles.includes(t)) && KOKUSHI_TILES.some(t => countTiles(state.handTiles, t) === 2); + }, + }, + { + name: 'tenho', + isYakuman: true, + calc: (state: EnvForCalcYaku) => { + return (state.firstTurn ?? false) && state.tsumoTile != null && state.seatWind === 'e'; + }, + }, + { + name: 'chiho', + isYakuman: true, + calc: (state: EnvForCalcYaku) => { + return (state.firstTurn ?? false) && state.tsumoTile != null && state.seatWind !== 'e'; + }, + }, +]; + +export function convertHuroForCalcYaku(huro: Huro): HuroForCalcYaku { + switch (huro.type) { + case 'pon': + case 'ankan': + case 'minkan': + return { + type: huro.type, + tile: TILE_ID_MAP.get(huro.tiles[0])!.t, + }; + case 'cii': + return { + type: 'cii', + tiles: huro.tiles.map(tile => TILE_ID_MAP.get(tile)!.t) as [TileType, TileType, TileType], + }; + } +} + +const NORMAL_YAKU_DATA_MAP = new Map>( + NORMAL_YAKU_DEFINITIONS.map(yaku => [yaku.name, { + name: yaku.name, + upper: yaku.upper ?? null, + fan: yaku.fan, + isYakuman: false, + kuisagari: yaku.kuisagari ?? false, + }] as const), +); + +const YAKUMAN_DATA_MAP = new Map>( + YAKUMAN_DEFINITIONS.map(yaku => [yaku.name, { + name: yaku.name, + upper: yaku.upper ?? null, + fan: null, + isYakuman: true, + isDoubleYakuman: yaku.isDoubleYakuman ?? false, + }]), +); + +export function calcYakusWithDetail(state: EnvForCalcYaku): YakuSet { + if (state.riichi && state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type)) ) { + throw new TypeError('Invalid riichi state with call huros'); + } + + const agariTile = state.tsumoTile ?? state.ronTile; + if (!state.handTiles.includes(agariTile)) { + throw new TypeError('Agari tile not included in hand tiles'); + } + + if (state.handTiles.length + state.huros.length * 3 !== 14) { + throw new TypeError('Invalid tile count'); + } + + const oneHeadFourMentsuPatterns: (FourMentsuOneJyantou | null)[] = analyzeFourMentsuOneJyantou(state.handTiles); + if (oneHeadFourMentsuPatterns.length === 0) oneHeadFourMentsuPatterns.push(null); + + const waitPatterns = oneHeadFourMentsuPatterns.map( + fourMentsuOneJyantou => calcWaitPatterns(fourMentsuOneJyantou, agariTile), + ).flat(); + + const yakumanPatterns = waitPatterns.map(fourMentsuOneJyantouWithWait => { + const matchedYakus: Required[] = []; + for (const yakuDef of YAKUMAN_DEFINITIONS) { + if (yakuDef.upper && matchedYakus.some(yaku => yaku.name === yakuDef.upper)) continue; + const matched = yakuDef.calc(state, fourMentsuOneJyantouWithWait); + if (matched) { + matchedYakus.push(YAKUMAN_DATA_MAP.get(yakuDef.name)!); + } + } + return matchedYakus; + }).filter(yakus => yakus.length > 0); + + if (yakumanPatterns.length > 0) { + return new YakumanSet(yakumanPatterns[0]); + } + + const yakuPatterns = waitPatterns.map( + fourMentsuOneJyantouWithWait => NORMAL_YAKU_DEFINITIONS.filter( + yakuDef => yakuDef.calc(state, fourMentsuOneJyantouWithWait), + ).map(yakuDef => NORMAL_YAKU_DATA_MAP.get(yakuDef.name)!), + ).filter(yakus => yakus.length > 0); + + const isMenzen = state.huros.some(huro => includes(CALL_HURO_TYPES, huro.type)); + + if (yakuPatterns.length === 0) { + return new NormalYakuSet(isMenzen, []); + } + + let maxYakus = yakuPatterns[0]; + let maxFan = 0; + for (const yakus of yakuPatterns) { + let fan = 0; + for (const yaku of yakus) { + if (yaku.kuisagari && !isMenzen) { + fan += yaku.fan! - 1; + } else { + fan += yaku.fan!; + } + } + if (fan > maxFan) { + maxFan = fan; + maxYakus = yakus; + } + } + + return new NormalYakuSet(isMenzen, maxYakus); +} + +export function calcYakus(state: EnvForCalcYaku): YakuName[] { + return calcYakusWithDetail(state).yakuNames; +} diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts new file mode 100644 index 0000000000..ea6fa07706 --- /dev/null +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -0,0 +1,1022 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import CRC32 from 'crc-32'; +import { TileType, House, Huro, TileId } from './common.js'; +import * as Common from './common.js'; +import { PlayerState } from './engine.player.js'; +import { calcYakusWithDetail, convertHuroForCalcYaku, YakuData, YakuSet } from './common.yaku.js'; + +export const INITIAL_POINT = 25000; + +//#region syntax suger +function $(tid: TileId): Common.TileInstance { + return Common.findTileByIdOrFail(tid); +} + +function $type(tid: TileId): TileType { + return $(tid).t; +} +//#endregion + +function shuffle(array: T): T { + let currentIndex = array.length, randomIndex; + + // While there remain elements to shuffle. + while (currentIndex > 0) { + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], array[currentIndex]]; + } + + return array; +} + +class StateManager { + public $state: MasterState; + private commitCallback?: (state: MasterState) => void; + + constructor(state: MasterState, commitCallback?: (state: MasterState) => void) { + this.$state = structuredClone(state); + this.commitCallback = commitCallback; + } + + public $commit() { + if (this.commitCallback) this.commitCallback(this.$state); + } + + public get doras(): TileType[] { + return this.$state.kingTiles.slice(0, this.$state.activatedDorasCount) + .map(id => Common.nextTileForDora($type(id))); + } + + public get handTiles(): Record { + return this.$state.handTiles; + } + + public get handTileTypes(): Record { + return { + e: this.$state.handTiles.e.map(id => $type(id)), + s: this.$state.handTiles.s.map(id => $type(id)), + w: this.$state.handTiles.w.map(id => $type(id)), + n: this.$state.handTiles.n.map(id => $type(id)), + }; + } + + public get hoTileTypes(): Record { + return { + e: this.$state.hoTiles.e.map(id => $type(id)), + s: this.$state.hoTiles.s.map(id => $type(id)), + w: this.$state.hoTiles.w.map(id => $type(id)), + n: this.$state.hoTiles.n.map(id => $type(id)), + }; + } + + public get riichis(): Record { + return this.$state.riichis; + } + + public get askings(): MasterState['askings'] { + return this.$state.askings; + } + + public get user1House(): House { + return this.$state.user1House; + } + + public get user2House(): House { + return this.$state.user2House; + } + + public get user3House(): House { + return this.$state.user3House; + } + + public get user4House(): House { + return this.$state.user4House; + } + + public get turn(): House | null { + return this.$state.turn; + } + + public canRon(house: House, tid: TileId): boolean { + // フリテン + // TODO: ポンされるなどして自分の河にない場合の考慮 + if (this.hoTileTypes[house].includes($type(tid))) return false; + + if (!Common.isAgarikei(this.handTileTypes[house].concat($type(tid)))) return false; // 完成形じゃない + + // TODO + //const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc(this.state, { tsumoTile: null, ronTile: tile })); + //if (yakus.length === 0) return false; // 役がない + + return true; + } + + public canPon(house: House, tid: TileId): boolean { + return this.handTileTypes[house].filter(t => t === $type(tid)).length === 2; + } + + public canDaiminkan(caller: House, tid: TileId): boolean { + return this.handTileTypes[caller].filter(t => t === $type(tid)).length === 3; + } + + public canCii(caller: House, callee: House, tid: TileId): boolean { + if (callee !== Common.prevHouse(caller)) return false; + const hand = this.handTileTypes[caller]; + return Common.SHUNTU_PATTERNS.some(pattern => + pattern.includes($type(tid)) && + pattern.filter(t => hand.includes(t) && t !== $type(tid)).length >= 2); + } + + private withTsumoTile(tile: TileId | undefined, isRinshan: boolean): TileId { + if (tile == null) throw new Error('No tiles left'); + if (this.$state.turn == null) throw new Error('Not your turn'); + this.$state.handTiles[this.$state.turn].push(tile); + this.$state.rinshanFlags[this.$state.turn] = isRinshan; + return tile; + } + + public tsumo(): TileId { + return this.withTsumoTile(this.$state.tiles.pop(), false); + } + + public rinshanTsumo(): TileId { + return this.withTsumoTile(this.$state.tiles.shift(), true); + } + + public clearFirstTurnAndIppatsus(): void { + this.$state.firstTurnFlags.e = false; + this.$state.firstTurnFlags.s = false; + this.$state.firstTurnFlags.w = false; + this.$state.firstTurnFlags.n = false; + + this.$state.ippatsus.e = false; + this.$state.ippatsus.s = false; + this.$state.ippatsus.w = false; + this.$state.ippatsus.n = false; + } +} + +export type MasterState = { + user1House: House; + user2House: House; + user3House: House; + user4House: House; + + round: 'e' | 's' | 'w' | 'n'; + kyoku: number; + turnCount: number; + tiles: TileId[]; + kingTiles: TileId[]; + activatedDorasCount: number; + + /** + * 副露した牌を含まない手牌 + */ + handTiles: { + e: TileId[]; + s: TileId[]; + w: TileId[]; + n: TileId[]; + }; + + hoTiles: { + e: TileId[]; + s: TileId[]; + w: TileId[]; + n: TileId[]; + }; + huros: { + e: Huro[]; + s: Huro[]; + w: Huro[]; + n: Huro[]; + }; + firstTurnFlags: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + riichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + doubleRiichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + ippatsus: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + rinshanFlags: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + } + points: { + e: number; + s: number; + w: number; + n: number; + }; + turn: House | null; + nextTurnAfterAsking: House | null; + askings: { + ron: { + /** + * 牌を捨てた人 + */ + callee: House; + + /** + * ロンする権利がある人 + */ + callers: House[]; + } | null; + + pon: { + /** + * 牌を捨てた人 + */ + callee: House; + + /** + * ポンする権利がある人 + */ + caller: House; + } | null; + + cii: { + /** + * 牌を捨てた人 + */ + callee: House; + + /** + * チーする権利がある人(calleeの下家なのは自明だがプログラム簡略化のため) + */ + caller: House; + } | null; + + kan: { + /** + * 牌を捨てた人 + */ + callee: House; + + /** + * カンする権利がある人 + */ + caller: House; + } | null; + }; +}; + +export class MasterGameEngine { + private stateManager: StateManager; + + constructor(state: MasterState) { + this.stateManager = new StateManager(state); + } + + public get $state() { + return this.stateManager.$state; + } + + public get doras(): TileType[] { + return this.stateManager.doras; + } + + public get handTiles(): Record { + return this.stateManager.handTiles; + } + + public get handTileTypes(): Record { + return this.stateManager.handTileTypes; + } + + public get hoTileTypes(): Record { + return this.stateManager.hoTileTypes; + } + + public get riichis(): Record { + return this.stateManager.riichis; + } + + public get askings(): MasterState['askings'] { + return this.stateManager.askings; + } + + public get user1House(): House { + return this.stateManager.user1House; + } + + public get user2House(): House { + return this.stateManager.user2House; + } + + public get user3House(): House { + return this.stateManager.user3House; + } + + public get user4House(): House { + return this.stateManager.user4House; + } + + public get turn(): House | null { + return this.stateManager.turn; + } + + public static createInitialState(preset: Partial = {}): MasterState { + const ikasama: TileId[] = [125, 129, 9, 56, 57, 61, 77, 81, 85, 133, 134, 135, 121, 122]; + + const tiles = shuffle([...Common.TILE_ID_MAP.keys()]); + + //for (const tile of ikasama) { + // const index = tiles.indexOf(tile); + // tiles.splice(index, 1); + //} + + const eHandTiles = tiles.splice(0, 14); + //const eHandTiles = ikasama; + const sHandTiles = tiles.splice(0, 13); + const wHandTiles = tiles.splice(0, 13); + const nHandTiles = tiles.splice(0, 13); + const kingTiles = tiles.splice(0, 14); + + return { + user1House: 'e', + user2House: 's', + user3House: 'w', + user4House: 'n', + round: 'e', + kyoku: 1, + turnCount: 0, + tiles, + kingTiles, + activatedDorasCount: 1, + handTiles: { + e: eHandTiles, + s: sHandTiles, + w: wHandTiles, + n: nHandTiles, + }, + hoTiles: { + e: [], + s: [], + w: [], + n: [], + }, + huros: { + e: [], + s: [], + w: [], + n: [], + }, + firstTurnFlags: { + e: true, + s: true, + w: true, + n: true, + }, + riichis: { + e: false, + s: false, + w: false, + n: false, + }, + doubleRiichis: { + e: false, + s: false, + w: false, + n: false, + }, + ippatsus: { + e: false, + s: false, + w: false, + n: false, + }, + rinshanFlags: { + e: false, + s: false, + w: false, + n: false, + }, + points: { + e: INITIAL_POINT, + s: INITIAL_POINT, + w: INITIAL_POINT, + n: INITIAL_POINT, + }, + turn: 'e', + nextTurnAfterAsking: null, + askings: { + ron: null, + pon: null, + cii: null, + kan: null, + }, + ...preset, + }; + } + + public getHouse(index: 1 | 2 | 3 | 4): House { + switch (index) { + case 1: return this.stateManager.user1House; + case 2: return this.stateManager.user2House; + case 3: return this.stateManager.user3House; + case 4: return this.stateManager.user4House; + } + } + + public startTransaction() { + return new StateManager(this.stateManager.$state, (newState) => { + this.stateManager = new StateManager(newState); + }); + } + + public commit_nextKyoku() { + const tx = this.startTransaction(); + const newState = MasterGameEngine.createInitialState(); + newState.kyoku = tx.$state.kyoku + 1; + newState.points = tx.$state.points; + newState.turn = 'e'; + newState.user1House = Common.nextHouse(tx.$state.user1House); + newState.user2House = Common.nextHouse(tx.$state.user2House); + newState.user3House = Common.nextHouse(tx.$state.user3House); + newState.user4House = Common.nextHouse(tx.$state.user4House); + tx.$state = newState; + tx.$commit(); + } + + public commit_dahai(house: House, tid: TileId, riichi = false) { + const tx = this.startTransaction(); + + if (tx.$state.turn !== house) throw new Error('Not your turn'); + + if (riichi) { + if (tx.$state.riichis[house]) throw new Error('Already riichi'); + const tempHandTiles = [...tx.handTileTypes[house]]; + tempHandTiles.splice(tempHandTiles.indexOf($type(tid)), 1); + if (!Common.isTenpai(tempHandTiles)) throw new Error('Not tenpai'); + if (tx.$state.points[house] < 1000) throw new Error('Not enough points'); + } + + const handTiles = tx.$state.handTiles[house]; + if (!handTiles.includes(tid)) throw new Error('No such tile in your hand'); + handTiles.splice(handTiles.indexOf(tid), 1); + tx.$state.hoTiles[house].push(tid); + + if (tx.$state.riichis[house]) { + tx.$state.ippatsus[house] = false; + } + + if (riichi) { + tx.$state.riichis[house] = true; + tx.$state.ippatsus[house] = true; + if (tx.$state.firstTurnFlags[house]) { + tx.$state.doubleRiichis[house] = true; + } + } + + tx.$state.firstTurnFlags[house] = false; + tx.$state.rinshanFlags[house] = false; + + const canRonHouses: House[] = []; + switch (house) { + case 'e': + if (tx.canRon('s', tid)) canRonHouses.push('s'); + if (tx.canRon('w', tid)) canRonHouses.push('w'); + if (tx.canRon('n', tid)) canRonHouses.push('n'); + break; + case 's': + if (tx.canRon('e', tid)) canRonHouses.push('e'); + if (tx.canRon('w', tid)) canRonHouses.push('w'); + if (tx.canRon('n', tid)) canRonHouses.push('n'); + break; + case 'w': + if (tx.canRon('e', tid)) canRonHouses.push('e'); + if (tx.canRon('s', tid)) canRonHouses.push('s'); + if (tx.canRon('n', tid)) canRonHouses.push('n'); + break; + case 'n': + if (tx.canRon('e', tid)) canRonHouses.push('e'); + if (tx.canRon('s', tid)) canRonHouses.push('s'); + if (tx.canRon('w', tid)) canRonHouses.push('w'); + break; + } + + let canKanHouse: House | null = null; + switch (house) { + case 'e': canKanHouse = tx.canDaiminkan('s', tid) ? 's' : tx.canDaiminkan('w', tid) ? 'w' : tx.canDaiminkan('n', tid) ? 'n' : null; break; + case 's': canKanHouse = tx.canDaiminkan('e', tid) ? 'e' : tx.canDaiminkan('w', tid) ? 'w' : tx.canDaiminkan('n', tid) ? 'n' : null; break; + case 'w': canKanHouse = tx.canDaiminkan('e', tid) ? 'e' : tx.canDaiminkan('s', tid) ? 's' : tx.canDaiminkan('n', tid) ? 'n' : null; break; + case 'n': canKanHouse = tx.canDaiminkan('e', tid) ? 'e' : tx.canDaiminkan('s', tid) ? 's' : tx.canDaiminkan('w', tid) ? 'w' : null; break; + } + + let canPonHouse: House | null = null; + switch (house) { + case 'e': canPonHouse = tx.canPon('s', tid) ? 's' : tx.canPon('w', tid) ? 'w' : tx.canPon('n', tid) ? 'n' : null; break; + case 's': canPonHouse = tx.canPon('e', tid) ? 'e' : tx.canPon('w', tid) ? 'w' : tx.canPon('n', tid) ? 'n' : null; break; + case 'w': canPonHouse = tx.canPon('e', tid) ? 'e' : tx.canPon('s', tid) ? 's' : tx.canPon('n', tid) ? 'n' : null; break; + case 'n': canPonHouse = tx.canPon('e', tid) ? 'e' : tx.canPon('s', tid) ? 's' : tx.canPon('w', tid) ? 'w' : null; break; + } + + let canCiiHouse: House | null = null; + switch (house) { + case 'e': canCiiHouse = tx.canCii('s', house, tid) ? 's' : tx.canCii('w', house, tid) ? 'w' : tx.canCii('n', house, tid) ? 'n' : null; break; + case 's': canCiiHouse = tx.canCii('e', house, tid) ? 'e' : tx.canCii('w', house, tid) ? 'w' : tx.canCii('n', house, tid) ? 'n' : null; break; + case 'w': canCiiHouse = tx.canCii('e', house, tid) ? 'e' : tx.canCii('s', house, tid) ? 's' : tx.canCii('n', house, tid) ? 'n' : null; break; + case 'n': canCiiHouse = tx.canCii('e', house, tid) ? 'e' : tx.canCii('s', house, tid) ? 's' : tx.canCii('w', house, tid) ? 'w' : null; break; + } + + if (canRonHouses.length > 0 || canKanHouse != null || canPonHouse != null || canCiiHouse != null) { + if (canRonHouses.length > 0) { + tx.$state.askings.ron = { + callee: house, + callers: canRonHouses, + }; + } + if (canKanHouse != null) { + tx.$state.askings.kan = { + callee: house, + caller: canKanHouse, + }; + } + if (canPonHouse != null) { + tx.$state.askings.pon = { + callee: house, + caller: canPonHouse, + }; + } + if (canCiiHouse != null) { + tx.$state.askings.cii = { + callee: house, + caller: canCiiHouse, + }; + } + tx.$state.turn = null; + tx.$state.nextTurnAfterAsking = Common.nextHouse(house); + tx.$commit(); + + return { + asking: true as const, + canRonHouses: canRonHouses, + canKanHouse: canKanHouse, + canPonHouse: canPonHouse, + canCiiHouse: canCiiHouse, + }; + } + + // 流局 + if (tx.$state.tiles.length === 0) { + tx.$state.turn = null; + tx.$commit(); + + return { + asking: false as const, + ryuukyoku: true as const, + }; + } + + tx.$state.turn = Common.nextHouse(house); + + const tsumoTile = tx.tsumo(); + + tx.$commit(); + + return { + asking: false as const, + tsumoTile: tsumoTile, + next: tx.$state.turn, + }; + } + + public commit_kakan(house: House, tid: TileId) { + const tx = this.startTransaction(); + + const pon = tx.$state.huros[house].find(h => h.type === 'pon' && $type(h.tiles[0]) === $type(tid)) as Huro & { type: 'pon' }; + if (pon == null) throw new Error('No such pon'); + tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(tid), 1); + const tiles = [tid, ...pon.tiles] as const; + tx.$state.huros[house].push({ type: 'minkan', tiles: tiles, from: pon.from }); + + tx.clearFirstTurnAndIppatsus(); + + tx.$state.activatedDorasCount++; + + const rinsyan = tx.rinshanTsumo(); + + tx.$commit(); + + return { + rinsyan, + tiles, + from: pon.from, + }; + } + + public commit_ankan(house: House, tid: TileId) { + const tx = this.startTransaction(); + + const t1 = tx.$state.handTiles[house].filter(t => $type(t) === $type(tid)).at(0); + if (t1 == null) throw new Error('No such tile'); + const t2 = tx.$state.handTiles[house].filter(t => $type(t) === $type(tid)).at(1); + if (t2 == null) throw new Error('No such tile'); + const t3 = tx.$state.handTiles[house].filter(t => $type(t) === $type(tid)).at(2); + if (t3 == null) throw new Error('No such tile'); + const t4 = tx.$state.handTiles[house].filter(t => $type(t) === $type(tid)).at(3); + if (t4 == null) throw new Error('No such tile'); + tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t1), 1); + tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t2), 1); + tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t3), 1); + tx.$state.handTiles[house].splice(tx.$state.handTiles[house].indexOf(t4), 1); + const tiles = [t1, t2, t3, t4] as const; + tx.$state.huros[house].push({ type: 'ankan', tiles: tiles }); + + tx.clearFirstTurnAndIppatsus(); + + tx.$state.activatedDorasCount++; + + const rinsyan = tx.rinshanTsumo(); + + tx.$commit(); + + return { + rinsyan, + tiles, + }; + } + + /** + * ツモ和了 + * @param house + */ + public commit_tsumoHora(house: House, doLog = true) { + const tx = this.startTransaction(); + + if (tx.$state.turn !== house) throw new Error('Not your turn'); + + const yakus = calcYakusWithDetail({ + seatWind: house, + handTiles: tx.handTileTypes[house], + huros: tx.$state.huros[house].map(convertHuroForCalcYaku), + tsumoTile: tx.handTileTypes[house].at(-1)!, + ronTile: null, + firstTurn: tx.$state.firstTurnFlags[house], + riichi: tx.$state.riichis[house], + doubleRiichi: tx.$state.doubleRiichis[house], + ippatsu: tx.$state.ippatsus[house], + rinshan: tx.$state.rinshanFlags[house], + haitei: tx.$state.tiles.length === 0, + }); + const doraCount = + Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) + + Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]); + const pointDeltas = Common.calcTsumoHoraPointDeltas(house, yakus); + tx.$state.points.e += pointDeltas.e; + tx.$state.points.s += pointDeltas.s; + tx.$state.points.w += pointDeltas.w; + tx.$state.points.n += pointDeltas.n; + if (doLog) console.log('yakus', house, yakus); + + tx.$commit(); + + return { + handTiles: tx.$state.handTiles[house], + tsumoTile: tx.$state.handTiles[house].at(-1)!, + yakus, + }; + } + + public commit_resolveCallingInterruption(answers: { + pon: boolean; + cii: false | 'x__' | '_x_' | '__x'; + kan: boolean; + ron: House[]; + }, doLog = true) { + const tx = this.startTransaction(); + + if (tx.$state.askings.pon == null && tx.$state.askings.cii == null && tx.$state.askings.kan == null && tx.$state.askings.ron == null) throw new Error(); + + const pon = tx.$state.askings.pon; + const cii = tx.$state.askings.cii; + const kan = tx.$state.askings.kan; + const ron = tx.$state.askings.ron; + + tx.$state.askings.pon = null; + tx.$state.askings.cii = null; + tx.$state.askings.kan = null; + tx.$state.askings.ron = null; + + if (ron != null && answers.ron.length > 0) { + const callers = answers.ron; + const callee = ron.callee; + + const yakus: { [K in House]?: YakuSet } = Object.fromEntries(callers.map(house => { + const ronTile = tx.hoTileTypes[callee].at(-1)!; + const yakus = calcYakusWithDetail({ + seatWind: house, + handTiles: tx.handTileTypes[house].concat([ronTile]), + huros: tx.$state.huros[house].map(convertHuroForCalcYaku), + tsumoTile: null, + ronTile, + firstTurn: tx.$state.firstTurnFlags[house], + riichi: tx.$state.riichis[house], + doubleRiichi: tx.$state.doubleRiichis[house], + ippatsu: tx.$state.ippatsus[house], + hotei: tx.$state.tiles.length === 0, + }); + const doraCount = + Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) + + Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]); + const point = Common.calcPoint(yakus, house === 'e'); + tx.$state.points[callee] -= point; + tx.$state.points[house] += point; + if (doLog) { + console.log('yakus', house, yakus); + } + return [house, yakus] as const; + })); + + tx.$commit(); + + return { + type: 'ronned' as const, + callers: ron.callers, + callee: ron.callee, + turn: null, + yakus, + }; + } else if (kan != null && answers.kan) { + // 大明槓 + + const tile = tx.$state.hoTiles[kan.callee].pop()!; + const t1 = tx.$state.handTiles[kan.caller].filter(t => $type(t) === $type(tile)).at(0); + if (t1 == null) throw new Error('No such tile'); + const t2 = tx.$state.handTiles[kan.caller].filter(t => $type(t) === $type(tile)).at(1); + if (t2 == null) throw new Error('No such tile'); + const t3 = tx.$state.handTiles[kan.caller].filter(t => $type(t) === $type(tile)).at(2); + if (t3 == null) throw new Error('No such tile'); + + tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t1), 1); + tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t2), 1); + tx.$state.handTiles[kan.caller].splice(tx.$state.handTiles[kan.caller].indexOf(t3), 1); + + const tiles = [tile, t1, t2, t3] as const; + tx.$state.huros[kan.caller].push({ type: 'minkan', tiles: tiles, from: kan.callee }); + + tx.clearFirstTurnAndIppatsus(); + + tx.$state.activatedDorasCount++; + + const rinsyan = tx.rinshanTsumo(); + + tx.$state.turn = kan.caller; + + tx.$commit(); + + return { + type: 'kanned' as const, + caller: kan.caller, + callee: kan.callee, + tiles: tiles, + rinsyan, + turn: tx.$state.turn, + }; + } else if (pon != null && answers.pon) { + const tile = tx.$state.hoTiles[pon.callee].pop()!; + const t1 = tx.$state.handTiles[pon.caller].filter(t => $type(t) === $type(tile)).at(0); + if (t1 == null) throw new Error('No such tile'); + const t2 = tx.$state.handTiles[pon.caller].filter(t => $type(t) === $type(tile)).at(1); + if (t2 == null) throw new Error('No such tile'); + + tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t1), 1); + tx.$state.handTiles[pon.caller].splice(tx.$state.handTiles[pon.caller].indexOf(t2), 1); + + const tiles = [tile, t1, t2] as const; + tx.$state.huros[pon.caller].push({ type: 'pon', tiles: tiles, from: pon.callee }); + + tx.clearFirstTurnAndIppatsus(); + + tx.$state.turn = pon.caller; + + tx.$commit(); + + return { + type: 'ponned' as const, + caller: pon.caller, + callee: pon.callee, + tiles: tiles, + turn: tx.$state.turn, + }; + } else if (cii != null && answers.cii) { + const tile = tx.$state.hoTiles[cii.callee].pop()!; + let tiles: [TileId, TileId, TileId]; + + switch (answers.cii) { + case 'x__': { + const a = Common.NEXT_TILE_FOR_SHUNTSU[$type(tile)]; + if (a == null) throw new Error(); + const b = Common.NEXT_TILE_FOR_SHUNTSU[a]; + if (b == null) throw new Error(); + const aTile = tx.$state.handTiles[cii.caller].find(t => $type(t) === a); + if (aTile == null) throw new Error(); + const bTile = tx.$state.handTiles[cii.caller].find(t => $type(t) === b); + if (bTile == null) throw new Error(); + tx.$state.handTiles[cii.caller].splice(tx.$state.handTiles[cii.caller].indexOf(aTile), 1); + tx.$state.handTiles[cii.caller].splice(tx.$state.handTiles[cii.caller].indexOf(bTile), 1); + tiles = [tile, aTile, bTile]; + break; + } + case '_x_': { + const a = Common.PREV_TILE_FOR_SHUNTSU[$type(tile)]; + if (a == null) throw new Error(); + const b = Common.NEXT_TILE_FOR_SHUNTSU[$type(tile)]; + if (b == null) throw new Error(); + const aTile = tx.$state.handTiles[cii.caller].find(t => $type(t) === a); + if (aTile == null) throw new Error(); + const bTile = tx.$state.handTiles[cii.caller].find(t => $type(t) === b); + if (bTile == null) throw new Error(); + tx.$state.handTiles[cii.caller].splice(tx.$state.handTiles[cii.caller].indexOf(aTile), 1); + tx.$state.handTiles[cii.caller].splice(tx.$state.handTiles[cii.caller].indexOf(bTile), 1); + tiles = [aTile, tile, bTile]; + break; + } + case '__x': { + const a = Common.PREV_TILE_FOR_SHUNTSU[$type(tile)]; + if (a == null) throw new Error(); + const b = Common.PREV_TILE_FOR_SHUNTSU[a]; + if (b == null) throw new Error(); + const aTile = tx.$state.handTiles[cii.caller].find(t => $type(t) === a); + if (aTile == null) throw new Error(); + const bTile = tx.$state.handTiles[cii.caller].find(t => $type(t) === b); + if (bTile == null) throw new Error(); + tx.$state.handTiles[cii.caller].splice(tx.$state.handTiles[cii.caller].indexOf(aTile), 1); + tx.$state.handTiles[cii.caller].splice(tx.$state.handTiles[cii.caller].indexOf(bTile), 1); + tiles = [bTile, aTile, tile]; + break; + } + } + + tx.$state.huros[cii.caller].push({ type: 'cii', tiles: tiles, from: cii.callee }); + + tx.clearFirstTurnAndIppatsus(); + + tx.$state.turn = cii.caller; + + tx.$commit(); + + return { + type: 'ciied' as const, + caller: cii.caller, + callee: cii.callee, + tiles: tiles, + turn: tx.$state.turn, + }; + } else if (tx.$state.tiles.length === 0) { + // 流局 + + tx.$state.turn = null; + tx.$state.nextTurnAfterAsking = null; + + tx.$commit(); + + return { + type: 'ryuukyoku' as const, + }; + } else { + tx.$state.turn = tx.$state.nextTurnAfterAsking!; + tx.$state.nextTurnAfterAsking = null; + + const tile = tx.tsumo(); + + tx.$commit(); + + return { + type: 'tsumo' as const, + house: tx.$state.turn, + tile, + turn: tx.$state.turn, + }; + } + } + + public createPlayerState(index: 1 | 2 | 3 | 4): PlayerState { + const house = this.getHouse(index); + + return { + user1House: this.$state.user1House, + user2House: this.$state.user2House, + user3House: this.$state.user3House, + user4House: this.$state.user4House, + round: this.$state.round, + kyoku: this.$state.kyoku, + turnCount: this.$state.turnCount, + tilesCount: this.$state.tiles.length, + doraIndicateTiles: this.$state.kingTiles.slice(0, this.$state.activatedDorasCount), + handTiles: { + e: house === 'e' ? this.$state.handTiles.e : this.$state.handTiles.e.map(() => 0), + s: house === 's' ? this.$state.handTiles.s : this.$state.handTiles.s.map(() => 0), + w: house === 'w' ? this.$state.handTiles.w : this.$state.handTiles.w.map(() => 0), + n: house === 'n' ? this.$state.handTiles.n : this.$state.handTiles.n.map(() => 0), + }, + hoTiles: { + e: this.$state.hoTiles.e, + s: this.$state.hoTiles.s, + w: this.$state.hoTiles.w, + n: this.$state.hoTiles.n, + }, + huros: { + e: this.$state.huros.e, + s: this.$state.huros.s, + w: this.$state.huros.w, + n: this.$state.huros.n, + }, + firstTurnFlags: { + e: this.$state.firstTurnFlags.e, + s: this.$state.firstTurnFlags.s, + w: this.$state.firstTurnFlags.w, + n: this.$state.firstTurnFlags.n, + }, + riichis: { + e: this.$state.riichis.e, + s: this.$state.riichis.s, + w: this.$state.riichis.w, + n: this.$state.riichis.n, + }, + doubleRiichis: { + e: this.$state.doubleRiichis.e, + s: this.$state.doubleRiichis.s, + w: this.$state.doubleRiichis.w, + n: this.$state.doubleRiichis.n, + }, + ippatsus: { + e: this.$state.ippatsus.e, + s: this.$state.ippatsus.s, + w: this.$state.ippatsus.w, + n: this.$state.ippatsus.n, + }, + rinshanFlags: { + e: this.$state.rinshanFlags.e, + s: this.$state.rinshanFlags.s, + w: this.$state.rinshanFlags.w, + n: this.$state.rinshanFlags.n, + }, + points: { + e: this.$state.points.e, + s: this.$state.points.s, + w: this.$state.points.w, + n: this.$state.points.n, + }, + latestDahaiedTile: null, + turn: this.$state.turn, + canPon: null, + canCii: null, + canKan: null, + canRon: null, + }; + } + + public calcCrc32ForUser1(): number { + throw new Error('TODO'); + } + + public calcCrc32ForUser2(): number { + throw new Error('TODO'); + } + + public calcCrc32ForUser3(): number { + throw new Error('TODO'); + } + + public calcCrc32ForUser4(): number { + throw new Error('TODO'); + } + + public getState(): MasterState { + return structuredClone(this.$state); + } +} + +function commit_dahai(state: MasterState): MasterState { + throw new Error('Not implemented'); +} diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts new file mode 100644 index 0000000000..874a83a745 --- /dev/null +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -0,0 +1,467 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import CRC32 from 'crc-32'; +import { TileType, House, Huro, TileId } from './common.js'; +import * as Common from './common.js'; +import { calcYakusWithDetail, convertHuroForCalcYaku } from './common.yaku.js'; + +//#region syntax suger +function $(tid: TileId): Common.TileInstance { + return Common.findTileByIdOrFail(tid); +} + +function $type(tid: TileId): TileType { + return $(tid).t; +} +//#endregion + +export type PlayerState = { + user1House: House; + user2House: House; + user3House: House; + user4House: House; + + round: 'e' | 's' | 'w' | 'n'; + kyoku: number; + + turnCount: number; + tilesCount: number; + doraIndicateTiles: TileId[]; + + /** + * 副露した牌を含まない手牌 + */ + handTiles: { + e: TileId[] | 0[]; + s: TileId[] | 0[]; + w: TileId[] | 0[]; + n: TileId[] | 0[]; + }; + + hoTiles: { + e: TileId[]; + s: TileId[]; + w: TileId[]; + n: TileId[]; + }; + huros: { + e: Huro[]; + s: Huro[]; + w: Huro[]; + n: Huro[]; + }; + firstTurnFlags: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + riichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + doubleRiichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + ippatsus: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + rinshanFlags: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + } + points: { + e: number; + s: number; + w: number; + n: number; + }; + latestDahaiedTile: TileId | null; + turn: House | null; + canPon: { callee: House } | null; + canCii: { callee: House } | null; + canKan: { callee: House } | null; // = 大明槓 + canRon: { callee: House } | null; +}; + +export type KyokuResult = { + yakus: { name: string; fan: number | null; isYakuman: boolean; }[]; + doraCount: number; + pointDeltas: { e: number; s: number; w: number; n: number; }; +}; + +export class PlayerGameEngine { + /** + * このエラーが発生したときはdesyncが疑われる + */ + public static InvalidOperationError = class extends Error {}; + + private myUserNumber: 1 | 2 | 3 | 4; + private state: PlayerState; + + constructor(myUserNumber: PlayerGameEngine['myUserNumber'], state: PlayerState) { + this.myUserNumber = myUserNumber; + this.state = state; + } + + public get doras(): TileType[] { + return this.state.doraIndicateTiles.map(t => Common.nextTileForDora($type(t))); + } + + public get points(): Record { + return this.state.points; + } + + public get handTiles(): Record { + return this.state.handTiles; + } + + public get hoTiles(): Record { + return this.state.hoTiles; + } + + public get huros(): Record { + return this.state.huros; + } + + public get turnCount(): number { + return this.state.turnCount; + } + + public get tilesCount(): number { + return this.state.tilesCount; + } + + public get canRon(): PlayerState['canRon'] { + return this.state.canRon; + } + + public get canPon(): PlayerState['canPon'] { + return this.state.canPon; + } + + public get canKan(): PlayerState['canKan'] { + return this.state.canKan; + } + + public get canCii(): PlayerState['canCii'] { + return this.state.canCii; + } + + public get turn(): House | null { + return this.state.turn; + } + + public get user1House(): House { + return this.state.user1House; + } + + public get user2House(): House { + return this.state.user2House; + } + + public get user3House(): House { + return this.state.user3House; + } + + public get user4House(): House { + return this.state.user4House; + } + + public get myHouse(): House { + switch (this.myUserNumber) { + case 1: return this.state.user1House; + case 2: return this.state.user2House; + case 3: return this.state.user3House; + case 4: return this.state.user4House; + } + } + + public get myHandTiles(): TileId[] { + return this.state.handTiles[this.myHouse] as TileId[]; + } + + public get myHandTileTypes(): TileType[] { + return this.myHandTiles.map(t => $type(t)); + } + + public get isMeRiichi(): boolean { + return this.state.riichis[this.myHouse]; + } + + public commit_nextKyoku(state: PlayerState) { + this.state = state; + } + + public commit_tsumo(house: House, tid: TileId) { + console.log('commit_tsumo', this.state.turn, house, tid); + this.state.tilesCount--; + this.state.turn = house; + if (house === this.myHouse) { + this.myHandTiles.push(tid); + } else { + this.state.handTiles[house].push(0); + } + } + + public commit_dahai(house: House, tid: TileId, riichi = false) { + console.log('commit_dahai', this.state.turn, house, tid, riichi); + if (this.state.turn !== house) throw new PlayerGameEngine.InvalidOperationError(); + + if (riichi) { + this.state.riichis[house] = true; + } + + if (house === this.myHouse) { + this.myHandTiles.splice(this.myHandTiles.indexOf(tid), 1); + this.state.hoTiles[this.myHouse].push(tid); + } else { + this.state.handTiles[house].pop(); + this.state.hoTiles[house].push(tid); + } + + this.state.turn = null; + + if (house === this.myHouse) { + this.state.canRon = null; + this.state.canPon = null; + this.state.canKan = null; + this.state.canCii = null; + } else { + const canRon = Common.isAgarikei(this.myHandTiles.concat(tid).map(id => $type(id))); + const canPon = !this.isMeRiichi && this.myHandTileTypes.filter(t => t === $type(tid)).length === 2; + const canKan = !this.isMeRiichi && this.myHandTileTypes.filter(t => t === $type(tid)).length === 3; + const canCii = !this.isMeRiichi && house === Common.prevHouse(this.myHouse) && + Common.SHUNTU_PATTERNS.some(pattern => + pattern.includes($type(tid)) && + pattern.filter(t => this.myHandTileTypes.includes(t) && t !== $type(tid)).length >= 2); + + this.state.canRon = canRon ? { callee: house } : null; + this.state.canPon = canPon ? { callee: house } : null; + this.state.canKan = canKan ? { callee: house } : null; + this.state.canCii = canCii ? { callee: house } : null; + } + } + + public commit_tsumoHora(house: House, handTiles: TileId[], tsumoTile: TileId): KyokuResult { + console.log('commit_tsumoHora', this.state.turn, house); + + const yakus = calcYakusWithDetail({ + seatWind: house, + handTiles: handTiles.map(id => $type(id)), + huros: this.state.huros[house].map(convertHuroForCalcYaku), + tsumoTile: $type(tsumoTile), + ronTile: null, + firstTurn: this.state.firstTurnFlags[house], + riichi: this.state.riichis[house], + doubleRiichi: this.state.doubleRiichis[house], + ippatsu: this.state.ippatsus[house], + rinshan: this.state.rinshanFlags[house], + haitei: this.state.tilesCount === 0, + }); + const doraCount = + Common.calcOwnedDoraCount(handTiles.map(id => $type(id)), this.state.huros[house], this.doras) + + Common.calcRedDoraCount(handTiles, this.state.huros[house]); + const pointDeltas = Common.calcTsumoHoraPointDeltas(house, yakus); + this.state.points.e += pointDeltas.e; + this.state.points.s += pointDeltas.s; + this.state.points.w += pointDeltas.w; + this.state.points.n += pointDeltas.n; + + return { + yakus: yakus.yakus, + doraCount, + pointDeltas, + }; + } + + /** + * ロンします + * @param callers ロンした人 + * @param callee 牌を捨てた人 + */ + public commit_ronHora(callers: House[], callee: House, handTiles: { + e: TileId[]; + s: TileId[]; + w: TileId[]; + n: TileId[]; + }): Record { + console.log('commit_ronHora', this.state.turn, callers, callee); + + this.state.canRon = null; + + const resultMap: Record = { + e: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, + s: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, + w: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, + n: { yakus: [], doraCount: 0, pointDeltas: { e: 0, s: 0, w: 0, n: 0 } }, + }; + + const ronTile = $type(this.state.hoTiles[callee].at(-1)!); + for (const house of callers) { + const yakus = calcYakusWithDetail({ + seatWind: house, + handTiles: handTiles[house].map(id => $type(id)).concat([ronTile]), + huros: this.state.huros[house].map(convertHuroForCalcYaku), + tsumoTile: null, + ronTile: ronTile, + firstTurn: this.state.firstTurnFlags[house], + riichi: this.state.riichis[house], + doubleRiichi: this.state.doubleRiichis[house], + ippatsu: this.state.ippatsus[house], + hotei: this.state.tilesCount === 0, + }); + const doraCount = + Common.calcOwnedDoraCount(handTiles[house].map(id => $type(id)), this.state.huros[house], this.doras) + + Common.calcRedDoraCount(handTiles[house], this.state.huros[house]); + const point = Common.calcPoint(yakus, house === 'e'); + this.state.points[callee] -= point; + this.state.points[house] += point; + resultMap[house].yakus = yakus.yakus; + resultMap[house].doraCount = doraCount; + resultMap[house].pointDeltas[callee] = -point; + resultMap[house].pointDeltas[house] = point; + } + + return { + e: callers.includes('e') ? resultMap.e : null, + s: callers.includes('s') ? resultMap.s : null, + w: callers.includes('w') ? resultMap.w : null, + n: callers.includes('n') ? resultMap.n : null, + }; + } + + /** + * ポンします + * @param caller ポンした人 + * @param callee 牌を捨てた人 + */ + public commit_pon(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId]) { + this.state.canPon = null; + + this.state.hoTiles[callee].pop(); + if (caller === this.myHouse) { + if (this.myHandTiles.includes(tiles[0])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[0]), 1); + if (this.myHandTiles.includes(tiles[1])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[1]), 1); + if (this.myHandTiles.includes(tiles[2])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[2]), 1); + } else { + this.state.handTiles[caller].unshift(); + this.state.handTiles[caller].unshift(); + } + this.state.huros[caller].push({ type: 'pon', tiles: tiles, from: callee }); + + this.state.turn = caller; + } + + /** + * 大明槓します + * @param caller 大明槓した人 + * @param callee 牌を捨てた人 + */ + public commit_kan(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId, TileId], rinsyan: TileId) { + this.state.canKan = null; + + this.state.hoTiles[callee].pop(); + if (caller === this.myHouse) { + if (this.myHandTiles.includes(tiles[0])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[0]), 1); + if (this.myHandTiles.includes(tiles[1])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[1]), 1); + if (this.myHandTiles.includes(tiles[2])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[2]), 1); + if (this.myHandTiles.includes(tiles[3])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[3]), 1); + } else { + this.state.handTiles[caller].unshift(); + this.state.handTiles[caller].unshift(); + this.state.handTiles[caller].unshift(); + } + this.state.huros[caller].push({ type: 'minkan', tiles: tiles, from: callee }); + + this.state.turn = caller; + } + + public commit_kakan(house: House, tiles: TileId[], rinsyan: TileId) { + console.log('commit_kakan', this.state.turn, house, tiles); + } + + public commit_ankan(house: House, tiles: TileId[], rinsyan: TileId) { + console.log('commit_kakan', this.state.turn, house, tiles); + } + + /** + * チーします + * @param caller チーした人 + * @param callee 牌を捨てた人 + */ + public commit_cii(caller: House, callee: House, tiles: readonly [TileId, TileId, TileId]) { + this.state.canCii = null; + + this.state.hoTiles[callee].pop(); + if (caller === this.myHouse) { + if (this.myHandTiles.includes(tiles[0])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[0]), 1); + if (this.myHandTiles.includes(tiles[1])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[1]), 1); + if (this.myHandTiles.includes(tiles[2])) this.myHandTiles.splice(this.myHandTiles.indexOf(tiles[2]), 1); + } else { + this.state.handTiles[caller].unshift(); + this.state.handTiles[caller].unshift(); + } + this.state.huros[caller].push({ type: 'cii', tiles: tiles, from: callee }); + + this.state.turn = caller; + } + + public commit_nop() { + this.state.canRon = null; + this.state.canPon = null; + this.state.canKan = null; + this.state.canCii = null; + } + + public get isMenzen(): boolean { + const calls = ['pon', 'cii', 'minkan']; + return this.state.huros[this.myHouse].filter(h => calls.includes(h.type)).length === 0; + } + + public canRiichi(): boolean { + if (this.state.turn !== this.myHouse) return false; + if (this.state.riichis[this.myHouse]) return false; + if (this.state.points[this.myHouse] < 1000) return false; + if (!this.isMenzen) return false; + if (Common.getTilesForRiichi(this.myHandTileTypes).length === 0) return false; + return true; + } + + public canAnkan(): boolean { + if (this.state.turn !== this.myHouse) return false; + return this.myHandTiles + .filter(t => this.myHandTiles + .filter(tt => $type(tt) === $type(t)).length >= 4).length > 0; + } + + public canKakan(): boolean { + if (this.state.turn !== this.myHouse) return false; + return this.state.huros[this.myHouse].filter(h => h.type === 'pon' && this.myHandTileTypes.includes($type(h.tiles[0]))).length > 0; + } + + public getAnkanableTiles(): TileId[] { + return this.myHandTiles.filter(t => this.myHandTiles.filter(tt => $type(tt) === $type(t)).length >= 4); + } + + public getKakanableTiles(): TileId[] { + return this.myHandTiles.filter(t => this.state.huros[this.myHouse].some(h => h.type === 'pon' && $type(t) === $type(h.tiles[0]))); + } + + public getState(): PlayerState { + return structuredClone(this.state); + } +} diff --git a/packages/misskey-mahjong/src/index.ts b/packages/misskey-mahjong/src/index.ts new file mode 100644 index 0000000000..da85bb6f22 --- /dev/null +++ b/packages/misskey-mahjong/src/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export * as Serializer from './serializer.js'; +export * from './common.js'; + +export { MasterGameEngine } from './engine.master.js'; +export type { MasterState } from './engine.master.js'; +export { PlayerGameEngine } from './engine.player.js'; +export type { PlayerState } from './engine.player.js'; diff --git a/packages/misskey-mahjong/src/serializer.ts b/packages/misskey-mahjong/src/serializer.ts new file mode 100644 index 0000000000..d01935c9de --- /dev/null +++ b/packages/misskey-mahjong/src/serializer.ts @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { TileType } from './common.js'; + +export type Player = 1 | 2 | 3 | 4; + +export type Operation = 'dahai'; + +export type OperationCode = 1; + +export type Log = { + time: number; + player: Player; + operation: Operation; + tile: TileType; +}; + +export type SerializedLog = [number, Player, OperationCode, TileCode]; + +export const TILE_MAP = { + 'm1': 1, + 'm2': 2, + 'm3': 3, + 'm4': 4, + 'm5': 5, + 'm6': 6, + 'm7': 7, + 'm8': 8, + 'm9': 9, + 'p1': 10, + 'p2': 11, + 'p3': 12, + 'p4': 13, + 'p5': 14, + 'p6': 15, + 'p7': 16, + 'p8': 17, + 'p9': 18, + 's1': 19, + 's2': 20, + 's3': 21, + 's4': 22, + 's5': 23, + 's6': 24, + 's7': 25, + 's8': 26, + 's9': 27, + 'e': 28, + 's': 29, + 'w': 30, + 'n': 31, + 'haku': 32, + 'hatsu': 33, + 'chun': 34, +} as const; + +export type TileCode = typeof TILE_MAP extends Record ? T : never; + +export function serializeTile(tile: TileType): TileCode { + return TILE_MAP[tile]; +} + +export function deserializeTile(tile: number): TileType { + return Object.keys(TILE_MAP).find(key => TILE_MAP[key as TileType] === tile) as TileType; +} + +export function serializeLogs(logs: Log[]): SerializedLog[] { + const _logs: SerializedLog[] = []; + + for (let i = 0; i < logs.length; i++) { + const log = logs[i]; + const timeDelta = i === 0 ? log.time : log.time - logs[i - 1].time; + + switch (log.operation) { + case 'dahai': + _logs.push([timeDelta, log.player, 1, serializeTile(log.tile)]); + break; + //case 'surrender': + // _logs.push([timeDelta, log.player, 1]); + // break; + } + } + + return _logs; +} + +export function deserializeLogs(logs: SerializedLog[]): Log[] { + const _logs: Log[] = []; + + let time = 0; + + for (const log of logs) { + const timeDelta = log[0]; + time += timeDelta; + + const player = log[1]; + const operation = log[2]; + + switch (operation) { + case 1: + _logs.push({ + time, + player: player, + operation: 'dahai', + tile: deserializeTile(log[3]), + }); + break; + //case 1: + // _logs.push({ + // time, + // player: player === 1, + // operation: 'surrender', + // }); + // break; + } + } + + return _logs; +} diff --git a/packages/misskey-mahjong/test/engine.ts b/packages/misskey-mahjong/test/engine.ts new file mode 100644 index 0000000000..bb767e3ca0 --- /dev/null +++ b/packages/misskey-mahjong/test/engine.ts @@ -0,0 +1,229 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Common from '../src/common.js'; +import { TileType, TileId } from '../src/common.js'; +import { MasterGameEngine, MasterState, INITIAL_POINT } from '../src/engine.master.js'; + +const TILES = [71, 132, 108, 51, 39, 19, 3, 86, 104, 18, 50, 7, 45, 82, 43, 34, 111, 78, 53, 105, 126, 91, 112, 75, 119, 55, 95, 93, 65, 9, 66, 52, 79, 32, 99, 109, 56, 5, 101, 92, 1, 37, 62, 23, 27, 117, 77, 14, 31, 96, 120, 130, 29, 135, 100, 17, 102, 124, 59, 89, 49, 115, 107, 97, 90, 48, 25, 110, 68, 15, 74, 129, 69, 61, 73, 81, 11, 41, 44, 84, 13, 40, 33, 58, 30, 8, 38, 10, 87, 125, 57, 121, 21, 2, 54, 46, 22, 4, 133, 16, 76, 70, 60, 103, 114, 122, 24, 88, 36, 123, 47, 12, 128, 118, 116, 63, 26, 94, 67, 131, 64, 35, 113, 134, 6, 127, 80, 72, 42, 98, 85, 20, 106, 136, 83, 28]; + +const INITIAL_TILES_LENGTH = 69; + +class TileSetBuilder { + private restTiles = [...TILES]; + + private handTiles: Record = { + e: null, + s: null, + w: null, + n: null, + }; + + private tiles = new Map; + + public setHandTiles(house: Common.House, tileTypes: TileType[]): this { + if (this.handTiles[house] != null) { + throw new TypeError(`Hand tiles of house '${house}' is already set`); + } + + const tiles = tileTypes.map(tile => { + const index = this.restTiles.findIndex(tileId => Common.TILE_ID_MAP.get(tileId)!.t === tile); + if (index === -1) { + throw new TypeError(`Tile '${tile}' is not left`); + } + return this.restTiles.splice(index, 1)[0]; + }); + + this.handTiles[house] = tiles; + + return this; + } + + /** + * 山のn番目(0始まり)の牌を指定する。nが負の場合、海底を-1として海底側から数える + */ + public setTile(n: number, tileType: TileType): this { + if (n < 0) { + n += INITIAL_TILES_LENGTH; + } + + if (n < 0 || n >= INITIAL_TILES_LENGTH) { + throw new RangeError(`Cannot set ${n}th tile`); + } + + const indexInTiles = INITIAL_TILES_LENGTH - n - 1; + + if (this.tiles.has(indexInTiles)) { + throw new TypeError(`${n}th tile is already set`); + } + + const indexInRestTiles = this.restTiles.findIndex(tileId => Common.TILE_ID_MAP.get(tileId)!.t === tileType); + if (indexInRestTiles === -1) { + throw new TypeError(`Tile '${tileType}' is not left`); + } + this.tiles.set(indexInTiles, this.restTiles.splice(indexInRestTiles, 1)[0]); + + return this; + } + + public build(): Pick { + const handTiles: MasterState['handTiles'] = { + e: this.handTiles.e ?? this.restTiles.splice(0, 14), + s: this.handTiles.s ?? this.restTiles.splice(0, 13), + w: this.handTiles.w ?? this.restTiles.splice(0, 13), + n: this.handTiles.n ?? this.restTiles.splice(0, 13), + }; + + const kingTiles: MasterState['kingTiles'] = this.restTiles.splice(0, 14); + + const tiles = [...this.restTiles]; + for (const [index, tile] of [...this.tiles.entries()].sort(([index1], [index2]) => index1 - index2)) { + tiles.splice(index, 0, tile); + } + + return { + tiles, + kingTiles, + handTiles, + }; + } +} + +function tsumogiri(engine: MasterGameEngine, riichi = false): void { + const house = engine.turn; + if (house == null) { + throw new Error('No one\'s turn'); + } + engine.commit_dahai(house, engine.handTiles[house].at(-1)!, riichi); +} + +function tsumogiriAndIgnore(engine: MasterGameEngine, riichi = false): void { + tsumogiri(engine, riichi); + if (engine.askings.pon != null || engine.askings.cii != null || engine.askings.kan != null || engine.askings.ron != null) { + engine.commit_resolveCallingInterruption({ + pon: false, + cii: false, + kan: false, + ron: [], + }); + } +} + +describe('Master game engine', () => { + it('tenho', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder().setHandTiles('e', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3']).build(), + )); + expect(engine.commit_tsumoHora('e', false).yakus.yakuNames).toEqual(['tenho']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT + 48000, + s: INITIAL_POINT - 16000, + w: INITIAL_POINT - 16000, + n: INITIAL_POINT - 16000, + }); + }); + + it('chiho', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('s', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3']) + .setTile(0, 'm3') + .build(), + )); + tsumogiriAndIgnore(engine); + expect(engine.commit_tsumoHora('s', false).yakus.yakuNames).toEqual(['chiho']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT - 16000, + s: INITIAL_POINT + 32000, + w: INITIAL_POINT - 8000, + n: INITIAL_POINT - 8000, + }); + }); + + it('rinshan', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('e', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'n']) + .setTile(-1, 'm3') + .build(), + )); + engine.commit_ankan('e', engine.$state.handTiles.e.at(-1)!); + expect(engine.commit_tsumoHora('e', false).yakus.yakuNames).toEqual(['tsumo', 'rinshan']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT + 3000, + s: INITIAL_POINT - 1000, + w: INITIAL_POINT - 1000, + n: INITIAL_POINT - 1000, + }); + }); + + it('double-riichi ippatsu tsumo', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('e', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 's']) + .setTile(3, 'm3') + .build(), + )); + tsumogiriAndIgnore(engine, true); + tsumogiriAndIgnore(engine); + tsumogiriAndIgnore(engine); + tsumogiriAndIgnore(engine); + expect(engine.commit_tsumoHora('e', false).yakus.yakuNames).toEqual(['tsumo', 'double-riichi', 'ippatsu']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT + 12000, + s: INITIAL_POINT - 4000, + w: INITIAL_POINT - 4000, + n: INITIAL_POINT - 4000, + }); + }); + + it('double-riichi haitei tsumo', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('s', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3']) + .setTile(-1, 'm3') + .build(), + )); + tsumogiriAndIgnore(engine); + tsumogiriAndIgnore(engine, true); + while (engine.$state.tiles.length > 0) { + tsumogiriAndIgnore(engine); + } + expect(engine.commit_tsumoHora('s', false).yakus.yakuNames).toEqual(['tsumo', 'double-riichi', 'haitei']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT - 4000, + s: INITIAL_POINT + 8000, + w: INITIAL_POINT - 2000, + n: INITIAL_POINT - 2000, + }); + }); + + it('double-riichi hotei', () => { + const engine = new MasterGameEngine(MasterGameEngine.createInitialState( + new TileSetBuilder() + .setHandTiles('e', ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 's']) + .setHandTiles('s', ['m3', 'm6', 'p2', 'p5', 'p8', 's4', 'e', 's', 'w', 'haku', 'hatsu', 'chun', 'chun']) + .setTile(-1, 'm3') + .build(), + )); + tsumogiriAndIgnore(engine, true); + while (engine.$state.tiles.length > 0) { + tsumogiriAndIgnore(engine); + } + tsumogiri(engine); + expect(engine.commit_resolveCallingInterruption({ + pon: false, + cii: false, + kan: false, + ron: ['e'], + }, false).yakus?.e?.yakuNames).toEqual(['double-riichi', 'hotei']); + expect(engine.$state.points).toEqual({ + e: INITIAL_POINT + 6000, + s: INITIAL_POINT - 6000, + w: INITIAL_POINT, + n: INITIAL_POINT, + }); + }); +}); diff --git a/packages/misskey-mahjong/test/fu.ts b/packages/misskey-mahjong/test/fu.ts new file mode 100644 index 0000000000..cb4eec1724 --- /dev/null +++ b/packages/misskey-mahjong/test/fu.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert'; +import { calcWaitPatterns } from '../src/common.fu'; +import { analyzeFourMentsuOneJyantou } from '../src/common'; + +describe('Fu', () => { + describe('Wait patterns', () => { + it('Ryanmen', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's1', 's1', 's7', 's8', 's9'], + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 's9'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'mentsu', + agariTile: 's9', + waitedTaatsu: ['s7', 's8'], + }]); + }); + + it('Kanchan', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's1', 's1', 's7', 's8', 's9'], + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 's8'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'mentsu', + agariTile: 's8', + waitedTaatsu: ['s7', 's9'], + }]); + }); + + it('Penchan', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's1', 's1', 's7', 's8', 's9'], + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 's7'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'mentsu', + agariTile: 's7', + waitedTaatsu: ['s8', 's9'], + }]); + }); + + it('Tanki', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 'e'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'head', + agariTile: 'e', + }]); + }); + + it('Nobetan', () => { + const fourMentsuOneJyantou = analyzeFourMentsuOneJyantou( + ['m1', 'm2', 'm3', 'm5', 'm6', 'm7', 'p2', 'p3', 'p4', 's3', 's4', 's5', 's6', 's6'], + )[0]; + assert.deepStrictEqual(calcWaitPatterns(fourMentsuOneJyantou, 's6'), [{ + ...fourMentsuOneJyantou, + waitedFor: 'head', + agariTile: 's6', + }]); + }); + }); +}); diff --git a/packages/misskey-mahjong/test/yaku.ts b/packages/misskey-mahjong/test/yaku.ts new file mode 100644 index 0000000000..ad3d9aa119 --- /dev/null +++ b/packages/misskey-mahjong/test/yaku.ts @@ -0,0 +1,1088 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import { calcYakus } from '../src/common.yaku.js'; + +describe('Yaku', () => { + describe('Riichi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + ronTile: 'm3', + riichi: true, + }), ['riichi']); + }); + }); + + describe('double-riichi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + ronTile: 'm3', + riichi: true, + doubleRiichi: true, + }), ['double-riichi']); + }); + }); + + describe('tsumo', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + riichi: false, + }), ['tsumo']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm2', 'm2', 'm8', 'm8', 'p5', 'p5', 'p7', 'p7', 's9', 's9', 'p2', 'p2'], + huros: [], + tsumoTile: 'p2', + }).includes('tsumo'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm2', 'm2', 'm8', 'm8', 'p5', 'p5', 'p7', 'p7', 's9', 's9', 'p2', 'p2'], + huros: [], + ronTile: 'p2', + }).includes('tsumo'), false); + }); + }); + + describe('white', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ type: 'ankan', tile: 'haku' }], + tsumoTile: 'm3', + riichi: true, + }).includes('white'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ type: 'pon', tile: 'haku' }], + tsumoTile: 'm3', + riichi: false, + }), ['white']); + }); + }); + + describe('red', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ type: 'ankan', tile: 'chun' }], + tsumoTile: 'm3', + riichi: true, + }).includes('red'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ type: 'pon', tile: 'chun' }], + tsumoTile: 'm3', + riichi: false, + }), ['red']); + }); + }); + + describe('green', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ type: 'ankan', tile: 'hatsu' }], + tsumoTile: 'm3', + riichi: true, + }).includes('green'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ type: 'pon', tile: 'hatsu' }], + tsumoTile: 'm3', + riichi: false, + }), ['green']); + }); + }); + + describe('field-wind', () => { + it('north', () => { + assert.deepStrictEqual(calcYakus({ + fieldWind: 'n', + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'n', 'n', 'n'], + huros: [], + tsumoTile: 'n', + }).includes('field-wind-n'), true); + }); + it('east', () => { + assert.deepStrictEqual(calcYakus({ + fieldWind: 'e', + seatWind: 'n', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'e', 'e', 'e'], + huros: [], + tsumoTile: 'e', + }).includes('field-wind-e'), true); + }); + it('south', () => { + assert.deepStrictEqual(calcYakus({ + fieldWind: 's', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 's', 's', 's'], + huros: [], + tsumoTile: 's', + }).includes('field-wind-s'), true); + }); + it('west', () => { + assert.deepStrictEqual(calcYakus({ + fieldWind: 'w', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'w', 'w', 'w'], + huros: [], + tsumoTile: 'w', + }).includes('field-wind-w'), true); + }); + }); + describe('seat-wind', () => { + it('north', () => { + assert.deepStrictEqual(calcYakus({ + fieldWind: 'e', + seatWind: 'n', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'n', 'n', 'n'], + huros: [], + ronTile: 'n', + }).includes('seat-wind-n'), true); + }); + it('east', () => { + assert.deepStrictEqual(calcYakus({ + fieldWind: 's', + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'e', 'e', 'e'], + huros: [], + ronTile: 'e', + }).includes('seat-wind-e'), true); + }); + it('south', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 's', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 's', 's', 's'], + huros: [], + ronTile: 's', + }).includes('seat-wind-s'), true); + }); + it('west', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'w', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3', 'w', 'w', 'w'], + huros: [], + ronTile: 'w', + }).includes('seat-wind-w'), true); + }); + }); + + describe('ippatsu', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + riichi: true, + tsumoTile: 'm3', + ippatsu: true, + }).includes('ippatsu'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + riichi: true, + ronTile: 'm3', + ippatsu: true, + }).includes('ippatsu'), true); + }); + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + riichi: true, + }).includes('ippatsu'), false); + }); + }); + + describe('rinshan', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'n', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ + type: 'ankan', + tile: 'n', + }], + tsumoTile: 'm3', + rinshan: true, + }).includes('rinshan'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'n', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'm3', 'm3'], + huros: [{ + type: 'ankan', + tile: 'n', + }], + tsumoTile: 'm3', + }).includes('rinshan'), false); + }); + }); + + describe('haitei', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + haitei: true, + }).includes('haitei'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + haitei: false, + }).includes('haitei'), false); + }); + }); + + describe('hotei', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + ronTile: 'm3', + hotei: true, + }).includes('hotei'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + ronTile: 'm3', + hotei: false, + }).includes('hotei'), false); + }); + }); + + describe('tanyao', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm2', 'm2', 'p6', 'p7', 'p8', 's3', 's3', 's3', 's4', 's5', 's6', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + }).includes('tanyao'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['p6', 'p7', 'p8', 's3', 's3', 's3', 's4', 's5', 's6', 'm3', 'm3'], + huros: [{ type: 'pon', tile: 'm2' }], + tsumoTile: 'm3', + }).includes('tanyao'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm2', 'm3', 'm3', 'm8', 'm8', 'p5', 'p5', 'p7', 'p7', 's8', 's8', 'p2', 'p2'], + huros: [], + tsumoTile: 'p2', + }).includes('tanyao'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p7', 'p8', 's3', 's3', 's3', 's4', 's5', 's6', 'm3', 'm3'], + ippatsu: true, + huros: [], + tsumoTile: 'm1', + }).includes('tanyao'), false); + }); + }); + + describe('pinfu', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's9', 's9', 's5', 's6', 's7'], + huros: [], + tsumoTile: 's7', + }).includes('pinfu'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's9', 's9', 's5', 's6', 's7'], + huros: [], + ronTile: 's7', + }).includes('pinfu'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p6', 'p6', 'p6', 'p5', 'p6', 'p7', 's9', 's9', 's5', 's6', 's7'], + huros: [], + tsumoTile: 's7', + }).includes('pinfu'), false); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p6', 'p6', 'p6', 'p5', 'p6', 'p7', 's9', 's9', 's7', 's8', 's9'], + huros: [], + tsumoTile: 's7', + }).includes('pinfu'), false); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p6', 'p7', 'p8', 'p5', 'p6', 'p7', 's9', 's9', 's5', 's6', 's7'], + huros: [], + ronTile: 's6', + }).includes('pinfu'), false); + }); + }); + + describe('iipeko', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'm2', 'm3', 'm4', 'p5', 'p6', 'p7', 's9', 's9', 's4', 's5', 's6'], + huros: [], + tsumoTile: 's6', + }).includes('iipeko'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p5', 'p6', 'p7', 's9', 's9', 's4', 's5', 's6'], + huros: [{ type: 'cii', tiles: ['m2', 'm3', 'm4'] }], + tsumoTile: 's6', + }).includes('iipeko'), false); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'm2', 'm3', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], + huros: [], + tsumoTile: 'p1', + }).includes('iipeko'), false); + }); + }); + describe('ryanpeko', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'm2', 'm3', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], + huros: [], + tsumoTile: 'p1', + }).includes('ryampeko'), true); + }); + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], + huros: [{ type: 'cii', tiles: ['m2', 'm3', 'm4'] }], + tsumoTile: 'p1', + }).includes('ryampeko'), false); + }); + }); + + describe('sanshoku-dojun', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p1', 'p2', 'p3', 's1', 's2', 's3', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + }).includes('sanshoku-dojun'), true); + }); + }); + + describe('sanshoku-doko', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm2', 'm2', 'p2', 'p2', 'p2', 's2', 's2', 's2', 's4', 's5', 's6', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + }).includes('sanshoku-doko'), true); + }); + }); + + describe('ittsu', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + }).includes('ittsu'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm3', 'm4', 's4', 's5', 's6', 's7', 's8', 's9', 'm3', 'm3'], + huros: [{ type: 'cii', tiles: ['s1', 's2', 's3'] }], + tsumoTile: 'm3', + }).includes('ittsu'), true); + }); + }); + + describe('chanta', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 's1', 's2', 's3', 's7', 's8', 's9', 'p7', 'p8', 'p9', 'haku', 'haku'], + huros: [], + tsumoTile: 'haku', + }).includes('chanta'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 's1', 's2', 's3', 's7', 's8', 's9', 'haku', 'haku'], + huros: [{ type: 'pon', tile: 'p9' }], + tsumoTile: 'haku', + }).includes('chanta'), true); + }); + }); + + describe('junchan', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 's1', 's2', 's3', 's7', 's8', 's9', 'p7', 'p8', 'p9', 'm9', 'm9'], + huros: [], + tsumoTile: 'm9', + }).includes('junchan'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 's1', 's2', 's3', 's7', 's8', 's9', 'm9', 'm9'], + huros: [{ type: 'pon', tile: 'p9' }], + tsumoTile: 'm9', + }).includes('junchan'), true); + }); + }); + + describe('chitoitsu', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm2', 'm2', 'm8', 'm8', 'p5', 'p5', 'p7', 'p7', 's9', 's9', 'p2', 'p2'], + huros: [], + tsumoTile: 'p2', + }).includes('chitoitsu'), true); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + handTiles: ['m2', 'm3', 'm4', 'm2', 'm3', 'm4', 'p5', 'p6', 'p7', 'p5', 'p6', 'p7', 'p1', 'p1'], + huros: [], + tsumoTile: 'p1', + }).includes('chitoitsu'), false); + }); + }); + + describe('toitoi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'p5', 'p5', 'p5', 's7', 's7', 's7', 'p2', 'p2'], + huros: [], + ronTile: 's7', + }).includes('toitoi'), true); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm2', 'm2', 'p5', 'p5', 'p5', 's7', 's7', 's7', 'p2', 'p2'], + huros: [{ type: 'pon', tile: 'm1' }], + tsumoTile: 'p2', + }).includes('toitoi'), true); + }); + }); + + describe('sananko', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'p5', 'p5', 'p5', 's7', 's8', 's9', 'p2', 'p2'], + huros: [], + tsumoTile: 'p2', + }).includes('sananko'), true); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'p5', 'p5', 'p5', 's7', 's8', 's9', 'p2', 'p2'], + huros: [{ type: 'ankan', tile: 'm2' }], + tsumoTile: 'p2', + }).includes('sananko'), true); + }); + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'p5', 'p5', 'p5', 's7', 's8', 's9', 'p2', 'p2'], + huros: [{ type: 'minkan', tile: 'm2' }], + tsumoTile: 'p2', + }).includes('sananko'), false); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'p5', 'p5', 'p5', 's7', 's8', 's9', 'p2', 'p2'], + huros: [], + ronTile: 'm2', + }).includes('sananko'), false); + }); + }); + + describe('honroto', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm9', 'm9', 'm9', 'p9', 'p9', 'p9', 'hatsu', 'hatsu', 'hatsu', 'n', 'n'], + huros: [], + ronTile: 'hatsu', + }).includes('honroto'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m9', 'm9', 'm9', 'p9', 'p9', 'p9', 'hatsu', 'hatsu', 'hatsu', 'n', 'n'], + huros: [{ type: 'pon', tile: 'm1' }], + tsumoTile: 'p9', + }).includes('honroto'), true); + }); + }); + + describe('sankantsu', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m9', 'm9', 'm9', 'n', 'n'], + huros: [{ type: 'ankan', tile: 'm1' }, { type: 'ankan', tile: 'm2' }, { type: 'minkan', tile: 'm3' }], + tsumoTile: 'm9', + }).includes('sankantsu'), true); + }); + }); + + describe('honitsu', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm5', 'm6', 'm7', 'm9', 'm9', 'm9', 'n', 'n'], + huros: [{ type: 'pon', tile: 'w' }], + tsumoTile: 'n', + }).includes('honitsu'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm5', 'm5', 'm6', 'm6', 'm7', 'm7', 'm9', 'm9', 'w', 'w', 'n', 'n'], + huros: [], + tsumoTile: 'n', + }).includes('honitsu'), true); + }); + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm5', 'm6', 'm7', 'm9', 'm9', 'm9', 'm8', 'm8'], + huros: [{ type: 'pon', tile: 'm3' }], + tsumoTile: 'm8', + }).includes('honitsu'), false); + }); + }); + + describe('chinitsu', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm5', 'm6', 'm7', 'm9', 'm9', 'm9', 'm8', 'm8'], + huros: [{ type: 'pon', tile: 'm3' }], + tsumoTile: 'm8', + }).includes('chinitsu'), true); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm2', 'm2', 'm4', 'm4', 'm5', 'm5', 'm6', 'm6', 'm7', 'm7', 'm9', 'm9'], + huros: [], + tsumoTile: 'm9', + }).includes('chinitsu'), true); + }); + }); + + describe('shosangen', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['haku', 'haku', 'haku', 'chun', 'chun', 'hatsu', 'hatsu', 'hatsu', 'm1', 'm1', 'm1', 'm2', 'm3', 'm4'], + huros: [], + tsumoTile: 'm2', + }).includes('shosangen'), true); + }); + }); + + describe('kokushi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm9'], + huros: [], + tsumoTile: 'm9', + }), ['kokushi']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm9', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm3'], + huros: [], + tsumoTile: 'm3', + }).includes('kokushi'), false); + }); + }); + + describe('kokushi-13', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm9', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm1'], + huros: [], + tsumoTile: 'm1', + }), ['kokushi-13']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm9', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm9'], + huros: [], + tsumoTile: 'm1', + }).includes('kokushi-13'), false); + }); + }); + + describe('suanko', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'e', 'e'], + huros: [], + tsumoTile: 'chun', + }), ['suanko']); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm2', 'm2', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'e', 'e'], + huros: [{ type: 'ankan', tile: 'm1' }], + tsumoTile: 'chun', + }), ['suanko']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'm2', 'm2', 'e', 'e', 'e'], + huros: [{ type: 'pon', tile: 'm1' }], + ronTile: 'e', + }).includes('suanko'), false); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'm2', 'm2', 'e', 'e', 'e'], + huros: [], + ronTile: 'e', + }).includes('suanko'), false); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'm2', 'm2', 'e', 'e', 'e'], + huros: [], + ronTile: 'e', + }).includes('suanko'), false); + }); + }); + + describe('suanko-tanki', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], + huros: [], + tsumoTile: 'e', + }), ['suanko-tanki']); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], + huros: [{ type: 'ankan', tile: 'm1' }], + tsumoTile: 'e', + }), ['suanko-tanki']); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], + huros: [], + ronTile: 'e', + }), ['suanko-tanki']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'e', 'e'], + huros: [], + tsumoTile: 'chun', + }).includes('suanko-tanki'), false); + }); + }); + + describe('daisangen', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['haku', 'haku', 'haku', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'p2', 'p3', 'p4', 's2', 's2'], + huros: [], + tsumoTile: 's2', + }), ['daisangen']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'p2', 'p2', 'p2', 's2', 's2'], + huros: [{ type: 'pon', tile: 'haku' }], + tsumoTile: 's2', + }), ['daisangen']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['haku', 'haku', 'haku', 'chun', 'chun', 'hatsu', 'hatsu', 'hatsu', 'm1', 'm1', 'm1', 'm2', 'm2', 'm2'], + huros: [], + tsumoTile: 'm2', + }).includes('daisangen'), false); + }); + }); + + describe('tsuiso', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['haku', 'haku', 'haku', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }), ['suanko-tanki', 'tsuiso']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [{ type: 'pon', tile: 'haku' }], + tsumoTile: 's', + }), ['tsuiso']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['e', 'e', 's', 's', 'w', 'w', 'n', 'n', 'haku', 'haku', 'hatsu', 'hatsu', 'chun', 'chun'], + huros: [], + tsumoTile: 's', + }), ['tsuiso']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }).includes('tsuiso'), false); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [{ type: 'pon', tile: 'm1' }], + tsumoTile: 's', + }).includes('tsuiso'), false); + }); + }); + + describe('shosushi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }), ['shosushi']); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'n', 'n', 'n', 'w', 'w', 'w', 's', 's'], + huros: [{ type: 'pon', tile: 'e' }], + tsumoTile: 's', + }), ['shosushi']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }).includes('shosushi'), false); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [{ type: 'pon', tile: 'm1' }], + tsumoTile: 's', + }).includes('shosushi'), false); + }); + }); + + describe('daisushi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's', 's'], + huros: [], + tsumoTile: 's', + }), ['suanko', 'daisushi']); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'n', 'n', 'n', 'w', 'w', 'w', 's', 's', 's'], + huros: [{ type: 'pon', tile: 'e' }], + tsumoTile: 's', + }), ['daisushi']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }).includes('daisushi'), false); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [{ type: 'pon', tile: 'm1' }], + tsumoTile: 'e', + }).includes('daisushi'), false); + }); + }); + + describe('ryuiso', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's4', 's6', 's6', 's6', 's8', 's8', 's8', 'hatsu', 'hatsu'], + huros: [], + tsumoTile: 'hatsu', + }), ['ryuiso']); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8'], + huros: [{ type: 'pon', tile: 'hatsu' }], + tsumoTile: 's8', + }), ['ryuiso']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8', 'haku', 'haku', 'haku'], + huros: [], + tsumoTile: 's2', + }).includes('ryuiso'), false); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8'], + huros: [{ type: 'pon', tile: 'haku' }], + tsumoTile: 's2', + }).includes('ryuiso'), false); + }); + }); + + describe('chinroto', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm9', 'm9', 'm9', 's1', 's1', 's1', 's9', 's9', 's9', 'p1', 'p1'], + huros: [], + tsumoTile: 'p1', + }), ['suanko-tanki', 'chinroto']); + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m9', 'm9', 'm9', 's1', 's1', 's1', 's9', 's9', 's9', 'p1', 'p1'], + huros: [{ type: 'pon', tile: 'm1' }], + tsumoTile: 'p1', + }), ['chinroto']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8', 'haku', 'haku', 'haku'], + huros: [], + tsumoTile: 's2', + }).includes('chinroto'), false); + }); + }); + + describe('sukantsu', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['p1', 'p1'], + huros: [{ type: 'ankan', tile: 'm1' }, { type: 'ankan', tile: 'm2' }, { type: 'minkan', tile: 'm3' }, { type: 'minkan', tile: 'chun' }], + tsumoTile: 'p1', + }), ['sukantsu']); + }); + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm2'], + huros: [], + tsumoTile: 'm2', + }).includes('sukantsu'), false); + }); + }); + + describe('churen', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm5'], + huros: [], + tsumoTile: 'm5', + }), ['churen']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm2'], + huros: [], + tsumoTile: 'm2', + }).includes('churen'), false); + }); + }); + + describe('churen-9', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm1'], + huros: [], + tsumoTile: 'm1', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm2'], + huros: [], + tsumoTile: 'm2', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm3'], + huros: [], + tsumoTile: 'm3', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm4'], + huros: [], + tsumoTile: 'm4', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm5'], + huros: [], + tsumoTile: 'm5', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm6'], + huros: [], + tsumoTile: 'm6', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm7'], + huros: [], + tsumoTile: 'm7', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm8'], + huros: [], + tsumoTile: 'm8', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm9'], + huros: [], + tsumoTile: 'm9', + }), ['churen-9']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm5'], + huros: [], + tsumoTile: 'm5', + }).includes('churen-9'), false); + }); + }); + + describe('tenho', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + firstTurn: true, + }), ['tenho']); + }); + }); + + describe('chiho', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + seatWind: 's', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + tsumoTile: 'm3', + firstTurn: true, + }), ['chiho']); + }); + }); +}); diff --git a/packages/misskey-mahjong/tsconfig.json b/packages/misskey-mahjong/tsconfig.json new file mode 100644 index 0000000000..a2240018bc --- /dev/null +++ b/packages/misskey-mahjong/tsconfig.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./built/", + "removeComments": true, + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "lib": [ + "esnext", + "dom" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/misskey-mahjong/tsconfig.test.json b/packages/misskey-mahjong/tsconfig.test.json new file mode 100644 index 0000000000..c79d4cb35f --- /dev/null +++ b/packages/misskey-mahjong/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "node"] + }, + "include": [ + "test/**/*" + ], +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 820d041852..92bd187318 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,9 @@ importers: misskey-js: specifier: workspace:* version: link:../misskey-js + misskey-mahjong: + specifier: workspace:* + version: link:../misskey-mahjong misskey-reversi: specifier: workspace:* version: link:../misskey-reversi @@ -817,6 +820,9 @@ importers: misskey-js: specifier: workspace:* version: link:../misskey-js + misskey-mahjong: + specifier: workspace:* + version: link:../misskey-mahjong misskey-reversi: specifier: workspace:* version: link:../misskey-reversi @@ -1405,6 +1411,49 @@ importers: specifier: 5.8.2 version: 5.8.2 + packages/misskey-mahjong: + 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 + version: 1.0.0(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0)(typescript@5.3.3))(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0))(eslint@9.22.0) + '@types/jest': + specifier: 29.5.12 + version: 29.5.12 + '@types/node': + specifier: 20.11.17 + version: 20.11.17 + '@typescript-eslint/eslint-plugin': + specifier: 6.18.1 + version: 6.18.1(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0)(typescript@5.3.3) + '@typescript-eslint/parser': + specifier: 6.18.1 + version: 6.18.1(eslint@9.22.0)(typescript@5.3.3) + jest: + specifier: 29.7.0 + version: 29.7.0(@types/node@20.11.17) + nodemon: + specifier: 3.0.2 + version: 3.0.2 + ts-jest: + specifier: 29.1.2 + version: 29.1.2(@babel/core@7.24.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.7))(esbuild@0.19.11)(jest@29.7.0(@types/node@20.11.17))(typescript@5.3.3) + ts-jest-resolver: + specifier: 2.0.1 + version: 2.0.1 + typescript: + specifier: 5.3.3 + version: 5.3.3 + packages/misskey-reversi: dependencies: crc-32: @@ -1963,8 +2012,14 @@ packages: '@discordapp/twemoji@15.1.0': resolution: {integrity: sha512-QdpV4ifTONAXvDjRrMohausZeGrQ1ac/Ox6togUh6Xl3XKJ/KAaMMuAEi0qsb0wDwoVTSZBll5Y6+N3hB2ktBw==} - '@emnapi/runtime@1.4.0': - resolution: {integrity: sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw==} + '@emnapi/runtime@1.4.3': + resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + + '@esbuild/aix-ppc64@0.19.11': + resolution: {integrity: sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} @@ -1978,6 +2033,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.19.11': + resolution: {integrity: sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.0': resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} engines: {node: '>=18'} @@ -1990,6 +2051,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.19.11': + resolution: {integrity: sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.0': resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} engines: {node: '>=18'} @@ -2002,6 +2069,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.19.11': + resolution: {integrity: sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.0': resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} engines: {node: '>=18'} @@ -2014,6 +2087,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.19.11': + resolution: {integrity: sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.0': resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} engines: {node: '>=18'} @@ -2026,6 +2105,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.19.11': + resolution: {integrity: sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.0': resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} engines: {node: '>=18'} @@ -2038,6 +2123,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.19.11': + resolution: {integrity: sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.0': resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} engines: {node: '>=18'} @@ -2050,6 +2141,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.19.11': + resolution: {integrity: sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.0': resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} engines: {node: '>=18'} @@ -2062,6 +2159,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.19.11': + resolution: {integrity: sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.0': resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} engines: {node: '>=18'} @@ -2074,6 +2177,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.19.11': + resolution: {integrity: sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.0': resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} engines: {node: '>=18'} @@ -2086,6 +2195,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.19.11': + resolution: {integrity: sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.0': resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} engines: {node: '>=18'} @@ -2098,6 +2213,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.19.11': + resolution: {integrity: sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.0': resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} engines: {node: '>=18'} @@ -2110,6 +2231,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.19.11': + resolution: {integrity: sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.0': resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} engines: {node: '>=18'} @@ -2122,6 +2249,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.19.11': + resolution: {integrity: sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.0': resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} engines: {node: '>=18'} @@ -2134,6 +2267,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.19.11': + resolution: {integrity: sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.0': resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} engines: {node: '>=18'} @@ -2146,6 +2285,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.19.11': + resolution: {integrity: sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.0': resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} engines: {node: '>=18'} @@ -2158,6 +2303,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.19.11': + resolution: {integrity: sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.0': resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} engines: {node: '>=18'} @@ -2182,6 +2333,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.19.11': + resolution: {integrity: sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.0': resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} engines: {node: '>=18'} @@ -2206,6 +2363,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.19.11': + resolution: {integrity: sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.0': resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} engines: {node: '>=18'} @@ -2218,6 +2381,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/sunos-x64@0.19.11': + resolution: {integrity: sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.0': resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} engines: {node: '>=18'} @@ -2230,6 +2399,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.19.11': + resolution: {integrity: sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.0': resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} engines: {node: '>=18'} @@ -2242,6 +2417,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.19.11': + resolution: {integrity: sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.0': resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} engines: {node: '>=18'} @@ -2254,6 +2435,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.19.11': + resolution: {integrity: sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.0': resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} engines: {node: '>=18'} @@ -2272,12 +2459,6 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/eslint-utils@4.6.1': - resolution: {integrity: sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -2722,6 +2903,14 @@ packages: '@misskey-dev/browser-image-resizer@2024.1.0': resolution: {integrity: sha512-4EnO0zLW5NDtng3Gaz5MuT761uiuoOuplwX18wBqgj8w56LTU5BjLn/vbHwDIIe0j2gwqDYhMb7bDjmr1/Fomg==} + '@misskey-dev/eslint-plugin@1.0.0': + resolution: {integrity: sha512-dh6UbcrNDVg5DD8k8Qh4ab30OPpuEYIlJCqaBV/lkIV8wNN/AfCJ2V7iTP8V8KjryM4t+sf5IqzQLQnT0mWI4A==} + peerDependencies: + '@typescript-eslint/eslint-plugin': '>= 6' + '@typescript-eslint/parser': '>= 6' + eslint: '>= 3' + eslint-plugin-import: '>= 2' + '@misskey-dev/eslint-plugin@2.1.0': resolution: {integrity: sha512-f++Vv1r3BQyGqEE0SB5algUZwRoTMZIYfVtpcuQ2fLuYUm0cQ5BBTs/gwAHPajVB2YD8F33gzPIReTKtuJyCwQ==} peerDependencies: @@ -4303,9 +4492,6 @@ packages: '@types/eslint@7.29.0': resolution: {integrity: sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==} - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -4345,6 +4531,9 @@ packages: '@types/istanbul-reports@3.0.1': resolution: {integrity: sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==} + '@types/jest@29.5.12': + resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + '@types/jest@29.5.14': resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} @@ -4399,6 +4588,9 @@ packages: '@types/node-fetch@2.6.11': resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==} + '@types/node@20.11.17': + resolution: {integrity: sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==} + '@types/node@22.13.10': resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} @@ -4480,8 +4672,8 @@ packages: '@types/sanitize-html@2.15.0': resolution: {integrity: sha512-71Z6PbYsVKfp4i6Jvr37s5ql6if1Q/iJQT80NbaSi7uGaG8CqBMXP0pk/EsURAOuGdk5IJCd/vnzKrR7S3Txsw==} - '@types/scheduler@0.26.0': - resolution: {integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==} + '@types/scheduler@0.23.0': + resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} '@types/seedrandom@2.4.34': resolution: {integrity: sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==} @@ -4564,6 +4756,17 @@ packages: '@types/yauzl@2.10.0': resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==} + '@typescript-eslint/eslint-plugin@6.18.1': + resolution: {integrity: sha512-nISDRYnnIpk7VCFrGcu1rnZfM1Dh9LRHnfgdkjcbi/l7g16VYRri3TjXi9Ir4lOZSw5N/gnV/3H7jIPQ8Q4daA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/eslint-plugin@8.26.0': resolution: {integrity: sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4588,6 +4791,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/parser@6.18.1': + resolution: {integrity: sha512-zct/MdJnVaRRNy9e84XnVtRv9Vf91/qqe+hZJtKanjojud4wAVy/7lXxJmMyX6X6J+xc6c//YEWvpeif8cAhWA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@typescript-eslint/parser@8.26.0': resolution: {integrity: sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4609,6 +4822,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/scope-manager@6.18.1': + resolution: {integrity: sha512-BgdBwXPFmZzaZUuw6wKiHKIovms97a7eTImjkXCZE04TGHysG+0hDQPmygyvgtkoB/aOQwSM/nWv3LzrOIQOBw==} + engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/scope-manager@8.26.0': resolution: {integrity: sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4621,9 +4838,15 @@ packages: resolution: {integrity: sha512-2nggXGX5F3YrsGN08pw4XpMLO1Rgtnn4AzTegC2MDesv6q3QaTU5yU7IbS1tf1IwCR0Hv/1EFygLn9ms6LIpDA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.30.1': - resolution: {integrity: sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/type-utils@6.18.1': + resolution: {integrity: sha512-wyOSKhuzHeU/5pcRDP2G2Ndci+4g653V43gXTpt4nbyoIOAASkGDA9JIAgbQCdCkcr1MvpSYWzxTz0olCn8+/Q==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true '@typescript-eslint/type-utils@8.26.0': resolution: {integrity: sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==} @@ -4646,6 +4869,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/types@6.18.1': + resolution: {integrity: sha512-4TuMAe+tc5oA7wwfqMtB0Y5OrREPF1GeJBAjqwgZh1lEMH5PJQgWgHGfYufVB51LtjD+peZylmeyxUXPfENLCw==} + engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/types@8.26.0': resolution: {integrity: sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4658,9 +4885,14 @@ packages: resolution: {integrity: sha512-VT7T1PuJF1hpYC3AGm2rCgJBjHL3nc+A/bhOp9sGMKfi5v0WufsX/sHCFBfNTx2F+zA6qBc/PD0/kLRLjdt8mQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.30.1': - resolution: {integrity: sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@6.18.1': + resolution: {integrity: sha512-fv9B94UAhywPRhUeeV/v+3SBDvcPiLxRZJw/xZeeGgRLQZ6rLMG+8krrJUyIf6s1ecWTzlsbp0rlw7n9sjufHA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true '@typescript-eslint/typescript-estree@8.26.0': resolution: {integrity: sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==} @@ -4680,11 +4912,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/typescript-estree@8.30.1': - resolution: {integrity: sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/utils@6.18.1': + resolution: {integrity: sha512-zZmTuVZvD1wpoceHvoQpOiewmWu3uP9FuTWo8vqpy2ffsmfCE8mklRPi+vmnIYAIk9t/4kOThri2QCDgor+OpQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - typescript: '>=4.8.4 <5.9.0' + eslint: ^7.0.0 || ^8.0.0 '@typescript-eslint/utils@8.26.0': resolution: {integrity: sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==} @@ -4707,12 +4939,9 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/utils@8.30.1': - resolution: {integrity: sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/visitor-keys@6.18.1': + resolution: {integrity: sha512-/kvt0C5lRqGoCfsbmm7/CwMqoSkY3zzHLIjdhHZQW3VFrnz7ATecOHR7nb7V+xn4286MBxfnQfQhAmCI0u+bJA==} + engines: {node: ^16.0.0 || >=18.0.0} '@typescript-eslint/visitor-keys@8.26.0': resolution: {integrity: sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==} @@ -4726,10 +4955,6 @@ packages: resolution: {integrity: sha512-RGLh5CRaUEf02viP5c1Vh1cMGffQscyHe7HPAzGpfmfflFg1wUz2rYxd+OZqwpeypYvZ8UxSxuIpF++fmOzEcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.30.1': - resolution: {integrity: sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} @@ -5343,6 +5568,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -6157,11 +6386,14 @@ packages: domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + domutils@3.0.1: + resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} + domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} - dotenv@16.4.7: - resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -6308,6 +6540,11 @@ packages: peerDependencies: esbuild: '>=0.12 <1' + esbuild@0.19.11: + resolution: {integrity: sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -6884,6 +7121,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -7511,6 +7753,10 @@ packages: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -7951,6 +8197,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + make-fetch-happen@13.0.0: resolution: {integrity: sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==} engines: {node: ^16.14.0 || >=18.0.0} @@ -8214,12 +8463,12 @@ packages: resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.4: - resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.4: + resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} engines: {node: '>=16 || 14 >=14.17'} minimist-options@4.1.0: @@ -8461,6 +8710,11 @@ packages: resolution: {integrity: sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==} engines: {node: '>=6.0.0'} + nodemon@3.0.2: + resolution: {integrity: sha512-9qIN2LNTrEzpOPBaWHTm4Asy1LxXLSickZStAQ4IZe7zsoIpD/A7LWxhZV3t4Zu352uBcqVnRsDXSMR2Sc3lTA==} + engines: {node: '>=10'} + hasBin: true + nodemon@3.1.9: resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==} engines: {node: '>=10'} @@ -9407,6 +9661,10 @@ packages: readable-stream@2.3.7: resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==} + readable-stream@3.6.0: + resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==} + engines: {node: '>= 6'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -10372,18 +10630,18 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@1.3.0: + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + ts-api-utils@2.0.1: resolution: {integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - ts-case-convert@2.1.0: resolution: {integrity: sha512-Ye79el/pHYXfoew6kqhMwCoxp4NWjKNcm2kBzpmEMIU9dd9aBmHNNFtZ+WTm0rz1ngyDmfqDXDlyUnBXayiD0w==} @@ -10391,6 +10649,30 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} + ts-jest-resolver@2.0.1: + resolution: {integrity: sha512-FolE73BqVZCs8/RbLKxC67iaAtKpBWx7PeLKFW2zJQlOf9j851I7JRxSDenri2NFvVH3QP7v3S8q1AmL24Zb9Q==} + + ts-jest@29.1.2: + resolution: {integrity: sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==} + engines: {node: ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + ts-map@1.0.3: resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==} @@ -10557,6 +10839,11 @@ packages: typeorm-aurora-data-api-driver: optional: true + typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.8.2: resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} @@ -10591,6 +10878,9 @@ packages: undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} @@ -11692,7 +11982,7 @@ snapshots: '@babel/traverse': 7.24.7 '@babel/types': 7.25.6 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11712,7 +12002,7 @@ snapshots: '@babel/traverse': 7.24.7 '@babel/types': 7.25.6 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11839,26 +12129,51 @@ snapshots: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 @@ -11869,36 +12184,71 @@ snapshots: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-typescript@7.23.3(@babel/core@7.23.5)': dependencies: '@babel/core': 7.23.5 @@ -11934,7 +12284,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 '@babel/parser': 7.25.6 '@babel/types': 7.25.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -12119,107 +12469,158 @@ snapshots: jsonfile: 5.0.0 universalify: 0.1.2 - '@emnapi/runtime@1.4.0': + '@emnapi/runtime@1.4.3': dependencies: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.19.11': + optional: true + '@esbuild/aix-ppc64@0.25.0': optional: true '@esbuild/aix-ppc64@0.25.2': optional: true + '@esbuild/android-arm64@0.19.11': + optional: true + '@esbuild/android-arm64@0.25.0': optional: true '@esbuild/android-arm64@0.25.2': optional: true + '@esbuild/android-arm@0.19.11': + optional: true + '@esbuild/android-arm@0.25.0': optional: true '@esbuild/android-arm@0.25.2': optional: true + '@esbuild/android-x64@0.19.11': + optional: true + '@esbuild/android-x64@0.25.0': optional: true '@esbuild/android-x64@0.25.2': optional: true + '@esbuild/darwin-arm64@0.19.11': + optional: true + '@esbuild/darwin-arm64@0.25.0': optional: true '@esbuild/darwin-arm64@0.25.2': optional: true + '@esbuild/darwin-x64@0.19.11': + optional: true + '@esbuild/darwin-x64@0.25.0': optional: true '@esbuild/darwin-x64@0.25.2': optional: true + '@esbuild/freebsd-arm64@0.19.11': + optional: true + '@esbuild/freebsd-arm64@0.25.0': optional: true '@esbuild/freebsd-arm64@0.25.2': optional: true + '@esbuild/freebsd-x64@0.19.11': + optional: true + '@esbuild/freebsd-x64@0.25.0': optional: true '@esbuild/freebsd-x64@0.25.2': optional: true + '@esbuild/linux-arm64@0.19.11': + optional: true + '@esbuild/linux-arm64@0.25.0': optional: true '@esbuild/linux-arm64@0.25.2': optional: true + '@esbuild/linux-arm@0.19.11': + optional: true + '@esbuild/linux-arm@0.25.0': optional: true '@esbuild/linux-arm@0.25.2': optional: true + '@esbuild/linux-ia32@0.19.11': + optional: true + '@esbuild/linux-ia32@0.25.0': optional: true '@esbuild/linux-ia32@0.25.2': optional: true + '@esbuild/linux-loong64@0.19.11': + optional: true + '@esbuild/linux-loong64@0.25.0': optional: true '@esbuild/linux-loong64@0.25.2': optional: true + '@esbuild/linux-mips64el@0.19.11': + optional: true + '@esbuild/linux-mips64el@0.25.0': optional: true '@esbuild/linux-mips64el@0.25.2': optional: true + '@esbuild/linux-ppc64@0.19.11': + optional: true + '@esbuild/linux-ppc64@0.25.0': optional: true '@esbuild/linux-ppc64@0.25.2': optional: true + '@esbuild/linux-riscv64@0.19.11': + optional: true + '@esbuild/linux-riscv64@0.25.0': optional: true '@esbuild/linux-riscv64@0.25.2': optional: true + '@esbuild/linux-s390x@0.19.11': + optional: true + '@esbuild/linux-s390x@0.25.0': optional: true '@esbuild/linux-s390x@0.25.2': optional: true + '@esbuild/linux-x64@0.19.11': + optional: true + '@esbuild/linux-x64@0.25.0': optional: true @@ -12232,6 +12633,9 @@ snapshots: '@esbuild/netbsd-arm64@0.25.2': optional: true + '@esbuild/netbsd-x64@0.19.11': + optional: true + '@esbuild/netbsd-x64@0.25.0': optional: true @@ -12244,30 +12648,45 @@ snapshots: '@esbuild/openbsd-arm64@0.25.2': optional: true + '@esbuild/openbsd-x64@0.19.11': + optional: true + '@esbuild/openbsd-x64@0.25.0': optional: true '@esbuild/openbsd-x64@0.25.2': optional: true + '@esbuild/sunos-x64@0.19.11': + optional: true + '@esbuild/sunos-x64@0.25.0': optional: true '@esbuild/sunos-x64@0.25.2': optional: true + '@esbuild/win32-arm64@0.19.11': + optional: true + '@esbuild/win32-arm64@0.25.0': optional: true '@esbuild/win32-arm64@0.25.2': optional: true + '@esbuild/win32-ia32@0.19.11': + optional: true + '@esbuild/win32-ia32@0.25.0': optional: true '@esbuild/win32-ia32@0.25.2': optional: true + '@esbuild/win32-x64@0.19.11': + optional: true + '@esbuild/win32-x64@0.25.0': optional: true @@ -12279,11 +12698,6 @@ snapshots: eslint: 9.22.0 eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.6.1(eslint@9.22.0)': - dependencies: - eslint: 9.22.0 - eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.12.1': {} '@eslint/compat@1.1.1': {} @@ -12291,7 +12705,7 @@ snapshots: '@eslint/config-array@0.19.2': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -12305,7 +12719,7 @@ snapshots: '@eslint/eslintrc@3.3.0': dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) espree: 10.3.0 globals: 14.0.0 ignore: 5.3.1 @@ -12536,7 +12950,7 @@ snapshots: '@img/sharp-wasm32@0.34.1': dependencies: - '@emnapi/runtime': 1.4.0 + '@emnapi/runtime': 1.4.3 optional: true '@img/sharp-win32-ia32@0.34.1': @@ -12764,7 +13178,7 @@ snapshots: '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.8.3)(vite@6.3.1(@types/node@22.14.0)(sass@1.86.3)(terser@5.39.0)(tsx@4.19.3))': dependencies: - glob: 10.4.5 + glob: 10.3.10 magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.8.3) vite: 6.3.1(@types/node@22.14.0)(sass@1.86.3)(terser@5.39.0)(tsx@4.19.3) @@ -12812,7 +13226,7 @@ snapshots: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.7.1 + semver: 7.6.3 tar: 6.2.1 transitivePeerDependencies: - encoding @@ -12868,6 +13282,13 @@ snapshots: '@misskey-dev/browser-image-resizer@2024.1.0': {} + '@misskey-dev/eslint-plugin@1.0.0(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0)(typescript@5.3.3))(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0))(eslint@9.22.0)': + dependencies: + '@typescript-eslint/eslint-plugin': 6.18.1(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.18.1(eslint@9.22.0)(typescript@5.3.3) + eslint: 9.22.0 + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0) + '@misskey-dev/eslint-plugin@2.1.0(@eslint/compat@1.1.1)(@stylistic/eslint-plugin@2.13.0(eslint@9.22.0)(typescript@5.8.2))(@typescript-eslint/eslint-plugin@8.26.0(@typescript-eslint/parser@8.26.0(eslint@9.22.0)(typescript@5.8.2))(eslint@9.22.0)(typescript@5.8.2))(@typescript-eslint/parser@8.26.0(eslint@9.22.0)(typescript@5.8.2))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@9.22.0)(typescript@5.8.2))(eslint@9.22.0))(eslint@9.22.0)(globals@16.0.0)': dependencies: '@eslint/compat': 1.1.1 @@ -13155,7 +13576,7 @@ snapshots: '@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.28.0 forwarded-parse: 2.1.2 - semver: 7.7.1 + semver: 7.6.3 transitivePeerDependencies: - supports-color @@ -13288,7 +13709,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.11.2 require-in-the-middle: 7.3.0 - semver: 7.7.1 + semver: 7.6.3 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -13300,7 +13721,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.11.2 require-in-the-middle: 7.3.0 - semver: 7.7.1 + semver: 7.6.3 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -13312,7 +13733,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.11.2 require-in-the-middle: 7.3.0 - semver: 7.7.1 + semver: 7.6.3 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -14449,7 +14870,7 @@ snapshots: '@stylistic/eslint-plugin@2.13.0(eslint@9.22.0)(typescript@5.8.2)': dependencies: - '@typescript-eslint/utils': 8.30.1(eslint@9.22.0)(typescript@5.8.2) + '@typescript-eslint/utils': 8.29.1(eslint@9.22.0)(typescript@5.8.2) eslint: 9.22.0 eslint-visitor-keys: 4.2.0 espree: 10.3.0 @@ -14775,8 +15196,6 @@ snapshots: '@types/estree': 1.0.7 '@types/json-schema': 7.0.15 - '@types/estree@1.0.6': {} - '@types/estree@1.0.7': {} '@types/express-serve-static-core@4.17.33': @@ -14824,6 +15243,11 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.0 + '@types/jest@29.5.12': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + '@types/jest@29.5.14': dependencies: expect: 29.7.0 @@ -14876,6 +15300,10 @@ snapshots: '@types/node': 22.14.0 form-data: 4.0.2 + '@types/node@20.11.17': + dependencies: + undici-types: 5.26.5 + '@types/node@22.13.10': dependencies: undici-types: 6.20.0 @@ -14948,7 +15376,7 @@ snapshots: '@types/react@18.0.28': dependencies: '@types/prop-types': 15.7.14 - '@types/scheduler': 0.26.0 + '@types/scheduler': 0.23.0 csstype: 3.1.3 '@types/readdir-glob@1.1.1': @@ -14965,7 +15393,7 @@ snapshots: dependencies: htmlparser2: 8.0.1 - '@types/scheduler@0.26.0': {} + '@types/scheduler@0.23.0': {} '@types/seedrandom@2.4.34': {} @@ -15039,6 +15467,26 @@ snapshots: '@types/node': 22.14.0 optional: true + '@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0)(typescript@5.3.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 6.18.1(eslint@9.22.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.18.1 + '@typescript-eslint/type-utils': 6.18.1(eslint@9.22.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.18.1(eslint@9.22.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.18.1 + debug: 4.4.0(supports-color@5.5.0) + eslint: 9.22.0 + graphemer: 1.4.0 + ignore: 5.3.1 + natural-compare: 1.4.0 + semver: 7.7.1 + ts-api-utils: 1.3.0(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/eslint-plugin@8.26.0(@typescript-eslint/parser@8.26.0(eslint@9.22.0)(typescript@5.8.2))(eslint@9.22.0)(typescript@5.8.2)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -15090,13 +15538,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.18.1 + '@typescript-eslint/types': 6.18.1 + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.18.1 + debug: 4.4.0(supports-color@5.5.0) + eslint: 9.22.0 + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.26.0(eslint@9.22.0)(typescript@5.8.2)': dependencies: '@typescript-eslint/scope-manager': 8.26.0 '@typescript-eslint/types': 8.26.0 '@typescript-eslint/typescript-estree': 8.26.0(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.26.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) eslint: 9.22.0 typescript: 5.8.2 transitivePeerDependencies: @@ -15108,7 +15569,7 @@ snapshots: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) eslint: 9.22.0 typescript: 5.8.2 transitivePeerDependencies: @@ -15120,12 +15581,17 @@ snapshots: '@typescript-eslint/types': 8.29.1 '@typescript-eslint/typescript-estree': 8.29.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.29.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) eslint: 9.22.0 typescript: 5.8.3 transitivePeerDependencies: - supports-color + '@typescript-eslint/scope-manager@6.18.1': + dependencies: + '@typescript-eslint/types': 6.18.1 + '@typescript-eslint/visitor-keys': 6.18.1 + '@typescript-eslint/scope-manager@8.26.0': dependencies: '@typescript-eslint/types': 8.26.0 @@ -15141,16 +15607,23 @@ snapshots: '@typescript-eslint/types': 8.29.1 '@typescript-eslint/visitor-keys': 8.29.1 - '@typescript-eslint/scope-manager@8.30.1': + '@typescript-eslint/type-utils@6.18.1(eslint@9.22.0)(typescript@5.3.3)': dependencies: - '@typescript-eslint/types': 8.30.1 - '@typescript-eslint/visitor-keys': 8.30.1 + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.3.3) + '@typescript-eslint/utils': 6.18.1(eslint@9.22.0)(typescript@5.3.3) + debug: 4.4.0(supports-color@5.5.0) + eslint: 9.22.0 + ts-api-utils: 1.3.0(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color '@typescript-eslint/type-utils@8.26.0(eslint@9.22.0)(typescript@5.8.2)': dependencies: '@typescript-eslint/typescript-estree': 8.26.0(typescript@5.8.2) '@typescript-eslint/utils': 8.26.0(eslint@9.22.0)(typescript@5.8.2) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) eslint: 9.22.0 ts-api-utils: 2.0.1(typescript@5.8.2) typescript: 5.8.2 @@ -15161,7 +15634,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.8.2) '@typescript-eslint/utils': 8.29.0(eslint@9.22.0)(typescript@5.8.2) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) eslint: 9.22.0 ts-api-utils: 2.0.1(typescript@5.8.2) typescript: 5.8.2 @@ -15172,26 +15645,41 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.29.1(typescript@5.8.3) '@typescript-eslint/utils': 8.29.1(eslint@9.22.0)(typescript@5.8.3) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) eslint: 9.22.0 ts-api-utils: 2.0.1(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color + '@typescript-eslint/types@6.18.1': {} + '@typescript-eslint/types@8.26.0': {} '@typescript-eslint/types@8.29.0': {} '@typescript-eslint/types@8.29.1': {} - '@typescript-eslint/types@8.30.1': {} + '@typescript-eslint/typescript-estree@6.18.1(typescript@5.3.3)': + dependencies: + '@typescript-eslint/types': 6.18.1 + '@typescript-eslint/visitor-keys': 6.18.1 + debug: 4.4.0(supports-color@5.5.0) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.7.1 + ts-api-utils: 1.3.0(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color '@typescript-eslint/typescript-estree@8.26.0(typescript@5.8.2)': dependencies: '@typescript-eslint/types': 8.26.0 '@typescript-eslint/visitor-keys': 8.26.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.4 @@ -15205,7 +15693,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.4 @@ -15215,11 +15703,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.29.1(typescript@5.8.2)': + dependencies: + '@typescript-eslint/types': 8.29.1 + '@typescript-eslint/visitor-keys': 8.29.1 + debug: 4.4.0(supports-color@5.5.0) + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.4 + semver: 7.7.1 + ts-api-utils: 2.0.1(typescript@5.8.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/typescript-estree@8.29.1(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.29.1 '@typescript-eslint/visitor-keys': 8.29.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.4 @@ -15229,19 +15731,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.30.1(typescript@5.8.2)': + '@typescript-eslint/utils@6.18.1(eslint@9.22.0)(typescript@5.3.3)': dependencies: - '@typescript-eslint/types': 8.30.1 - '@typescript-eslint/visitor-keys': 8.30.1 - debug: 4.4.0(supports-color@8.1.1) - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 + '@eslint-community/eslint-utils': 4.4.0(eslint@9.22.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.0 + '@typescript-eslint/scope-manager': 6.18.1 + '@typescript-eslint/types': 6.18.1 + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.3.3) + eslint: 9.22.0 semver: 7.7.1 - ts-api-utils: 2.1.0(typescript@5.8.2) - typescript: 5.8.2 transitivePeerDependencies: - supports-color + - typescript '@typescript-eslint/utils@8.26.0(eslint@9.22.0)(typescript@5.8.2)': dependencies: @@ -15265,6 +15767,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.29.1(eslint@9.22.0)(typescript@5.8.2)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.22.0) + '@typescript-eslint/scope-manager': 8.29.1 + '@typescript-eslint/types': 8.29.1 + '@typescript-eslint/typescript-estree': 8.29.1(typescript@5.8.2) + eslint: 9.22.0 + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.29.1(eslint@9.22.0)(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.22.0) @@ -15276,16 +15789,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.30.1(eslint@9.22.0)(typescript@5.8.2)': + '@typescript-eslint/visitor-keys@6.18.1': dependencies: - '@eslint-community/eslint-utils': 4.6.1(eslint@9.22.0) - '@typescript-eslint/scope-manager': 8.30.1 - '@typescript-eslint/types': 8.30.1 - '@typescript-eslint/typescript-estree': 8.30.1(typescript@5.8.2) - eslint: 9.22.0 - typescript: 5.8.2 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types': 6.18.1 + eslint-visitor-keys: 3.4.3 '@typescript-eslint/visitor-keys@8.26.0': dependencies: @@ -15302,11 +15809,6 @@ snapshots: '@typescript-eslint/types': 8.29.1 eslint-visitor-keys: 4.2.0 - '@typescript-eslint/visitor-keys@8.30.1': - dependencies: - '@typescript-eslint/types': 8.30.1 - eslint-visitor-keys: 4.2.0 - '@ungap/structured-clone@1.2.0': {} '@vitejs/plugin-vue@5.2.3(vite@6.3.1(@types/node@22.14.0)(sass@1.86.3)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.13(typescript@5.8.3))': @@ -15318,7 +15820,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -15629,14 +16131,14 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color optional: true agent-base@7.1.0: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -15759,7 +16261,7 @@ snapshots: archiver-utils@5.0.2: dependencies: - glob: 10.4.5 + glob: 10.3.10 graceful-fs: 4.2.11 is-stream: 2.0.1 lazystream: 1.0.1 @@ -15780,7 +16282,7 @@ snapshots: are-we-there-yet@2.0.0: dependencies: delegates: 1.0.0 - readable-stream: 3.6.2 + readable-stream: 3.6.0 optional: true arg@5.0.2: {} @@ -15959,13 +16461,13 @@ snapshots: b4a@1.6.4: {} - babel-jest@29.7.0(@babel/core@7.23.5): + babel-jest@29.7.0(@babel/core@7.24.7): dependencies: - '@babel/core': 7.23.5 + '@babel/core': 7.24.7 '@jest/transform': 29.7.0 '@types/babel__core': 7.20.0 babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.23.5) + babel-preset-jest: 29.6.3(@babel/core@7.24.7) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -16005,11 +16507,27 @@ snapshots: '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.5) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.5) - babel-preset-jest@29.6.3(@babel/core@7.23.5): + babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.7): dependencies: - '@babel/core': 7.23.5 + '@babel/core': 7.24.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.7) + + babel-preset-jest@29.6.3(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.5) + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.7) babel-walk@3.0.0-canary-5: dependencies: @@ -16034,7 +16552,7 @@ snapshots: bin-version-check@5.1.0: dependencies: bin-version: 6.0.0 - semver: 7.7.1 + semver: 7.6.3 semver-truncate: 3.0.0 bin-version@6.0.0: @@ -16107,6 +16625,10 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.2(browserslist@4.24.4) + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -16165,7 +16687,7 @@ snapshots: dependencies: '@npmcli/fs': 3.1.0 fs-minipass: 3.0.3 - glob: 10.4.5 + glob: 10.3.10 lru-cache: 10.4.3 minipass: 7.1.2 minipass-collect: 1.0.2 @@ -16557,6 +17079,21 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.3.0 + create-jest@29.7.0(@types/node@20.11.17): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.11.17) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + create-jest@29.7.0(@types/node@22.13.15): dependencies: '@jest/types': 29.6.3 @@ -16860,6 +17397,12 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + debug@4.4.0(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + debug@4.4.0(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -17043,13 +17586,19 @@ snapshots: domelementtype: 2.3.0 domhandler: 4.3.1 + domutils@3.0.1: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils@3.1.0: dependencies: dom-serializer: 2.0.0 domelementtype: 2.3.0 domhandler: 5.0.3 - dotenv@16.4.7: {} + dotenv@16.5.0: {} dunder-proto@1.0.1: dependencies: @@ -17283,11 +17832,37 @@ snapshots: esbuild-register@3.5.0(esbuild@0.25.2): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) esbuild: 0.25.2 transitivePeerDependencies: - supports-color + esbuild@0.19.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.11 + '@esbuild/android-arm': 0.19.11 + '@esbuild/android-arm64': 0.19.11 + '@esbuild/android-x64': 0.19.11 + '@esbuild/darwin-arm64': 0.19.11 + '@esbuild/darwin-x64': 0.19.11 + '@esbuild/freebsd-arm64': 0.19.11 + '@esbuild/freebsd-x64': 0.19.11 + '@esbuild/linux-arm': 0.19.11 + '@esbuild/linux-arm64': 0.19.11 + '@esbuild/linux-ia32': 0.19.11 + '@esbuild/linux-loong64': 0.19.11 + '@esbuild/linux-mips64el': 0.19.11 + '@esbuild/linux-ppc64': 0.19.11 + '@esbuild/linux-riscv64': 0.19.11 + '@esbuild/linux-s390x': 0.19.11 + '@esbuild/linux-x64': 0.19.11 + '@esbuild/netbsd-x64': 0.19.11 + '@esbuild/openbsd-x64': 0.19.11 + '@esbuild/sunos-x64': 0.19.11 + '@esbuild/win32-arm64': 0.19.11 + '@esbuild/win32-ia32': 0.19.11 + '@esbuild/win32-x64': 0.19.11 + esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -17381,6 +17956,16 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.22.0): + dependencies: + debug: 3.2.7(supports-color@8.1.1) + optionalDependencies: + '@typescript-eslint/parser': 6.18.1(eslint@9.22.0)(typescript@5.3.3) + eslint: 9.22.0 + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.26.0(eslint@9.22.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.22.0): dependencies: debug: 3.2.7(supports-color@8.1.1) @@ -17401,6 +17986,35 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint@9.22.0): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7(supports-color@8.1.1) + doctrine: 2.1.0 + eslint: 9.22.0 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.18.1(eslint@9.22.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.22.0) + hasown: 2.0.2 + is-core-module: 2.15.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 + semver: 6.3.1 + string.prototype.trimend: 1.0.8 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 6.18.1(eslint@9.22.0)(typescript@5.3.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@9.22.0)(typescript@5.8.2))(eslint@9.22.0): dependencies: '@rtsao/scc': 1.1.0 @@ -17494,12 +18108,12 @@ snapshots: '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.2 - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) escape-string-regexp: 4.0.0 eslint-scope: 8.3.0 eslint-visitor-keys: 4.2.0 @@ -17939,7 +18553,7 @@ snapshots: follow-redirects@1.15.9(debug@4.4.0): optionalDependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) for-each@0.3.3: dependencies: @@ -18137,6 +18751,14 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.3.10: + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.4 + minipass: 7.1.2 + path-scurry: 1.11.1 + glob@10.4.5: dependencies: foreground-child: 3.1.1 @@ -18356,7 +18978,7 @@ snapshots: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 - domutils: 3.1.0 + domutils: 3.0.1 entities: 4.5.0 htmlparser2@9.1.0: @@ -18381,7 +19003,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -18409,7 +19031,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color optional: true @@ -18417,14 +19039,14 @@ snapshots: https-proxy-agent@7.0.2: dependencies: agent-base: 7.1.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -18522,7 +19144,7 @@ snapshots: dependencies: '@ioredis/commands': 1.2.0 cluster-key-slot: 1.1.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -18756,7 +19378,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -18765,7 +19387,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -18777,6 +19399,12 @@ snapshots: iterare@1.2.1: {} + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -18821,6 +19449,25 @@ snapshots: - babel-plugin-macros - supports-color + jest-cli@29.7.0(@types/node@20.11.17): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.11.17) + exit: 0.1.2 + import-local: 3.1.0 + jest-config: 29.7.0(@types/node@20.11.17) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@29.7.0(@types/node@22.13.15): dependencies: '@jest/core': 29.7.0 @@ -18859,12 +19506,42 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@22.13.15): + jest-config@29.7.0(@types/node@20.11.17): dependencies: - '@babel/core': 7.23.5 + '@babel/core': 7.24.7 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.23.5) + babel-jest: 29.7.0(@babel/core@7.24.7) + chalk: 4.1.2 + ci-info: 3.7.1 + deepmerge: 4.2.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.11.17 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@22.13.15): + dependencies: + '@babel/core': 7.24.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.24.7) chalk: 4.1.2 ci-info: 3.7.1 deepmerge: 4.2.2 @@ -18891,10 +19568,10 @@ snapshots: jest-config@29.7.0(@types/node@22.14.0): dependencies: - '@babel/core': 7.23.5 + '@babel/core': 7.24.7 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.23.5) + babel-jest: 29.7.0(@babel/core@7.24.7) chalk: 4.1.2 ci-info: 3.7.1 deepmerge: 4.2.2 @@ -19101,7 +19778,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.7.1 + semver: 7.6.3 transitivePeerDependencies: - supports-color @@ -19146,6 +19823,18 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest@29.7.0(@types/node@20.11.17): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.1.0 + jest-cli: 29.7.0(@types/node@20.11.17) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@22.13.15): dependencies: '@jest/core': 29.7.0 @@ -19485,6 +20174,8 @@ snapshots: dependencies: semver: 7.7.1 + make-error@1.3.6: {} + make-fetch-happen@13.0.0: dependencies: '@npmcli/agent': 2.2.0 @@ -19849,7 +20540,7 @@ snapshots: micromark@4.0.0: dependencies: '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.0 @@ -19917,11 +20608,11 @@ snapshots: dependencies: brace-expansion: 2.0.1 - minimatch@9.0.4: + minimatch@9.0.3: dependencies: brace-expansion: 2.0.1 - minimatch@9.0.5: + minimatch@9.0.4: dependencies: brace-expansion: 2.0.1 @@ -20158,12 +20849,12 @@ snapshots: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.1 - glob: 10.4.5 + glob: 10.3.10 graceful-fs: 4.2.11 make-fetch-happen: 13.0.0 nopt: 7.2.0 proc-log: 4.2.0 - semver: 7.7.1 + semver: 7.6.3 tar: 6.2.1 which: 4.0.0 transitivePeerDependencies: @@ -20175,6 +20866,19 @@ snapshots: nodemailer@6.10.0: {} + nodemon@3.0.2: + dependencies: + chokidar: 4.0.3 + debug: 4.4.0(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.1 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.0 + undefsafe: 2.0.5 + nodemon@3.1.9: dependencies: chokidar: 4.0.3 @@ -20218,7 +20922,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.15.1 - semver: 7.7.1 + semver: 7.6.3 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -21146,6 +21850,13 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.0: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -21265,7 +21976,7 @@ snapshots: require-in-the-middle@7.3.0: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -21324,7 +22035,7 @@ snapshots: rimraf@5.0.10: dependencies: - glob: 10.4.5 + glob: 10.3.10 rollup@4.39.0: dependencies: @@ -21446,7 +22157,7 @@ snapshots: semver-truncate@3.0.0: dependencies: - semver: 7.7.1 + semver: 7.6.3 semver@5.7.1: {} @@ -21717,7 +22428,7 @@ snapshots: socks-proxy-agent@8.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) socks: 2.7.1 transitivePeerDependencies: - supports-color @@ -21826,7 +22537,7 @@ snapshots: arg: 5.0.2 bluebird: 3.7.2 check-more-types: 2.24.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) execa: 5.1.1 lazy-ass: 1.6.0 ps-tree: 1.2.0 @@ -21839,7 +22550,7 @@ snapshots: arg: 5.0.2 bluebird: 3.7.2 check-more-types: 2.24.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) execa: 5.1.1 lazy-ass: 1.6.0 ps-tree: 1.2.0 @@ -22232,6 +22943,10 @@ snapshots: trough@2.2.0: {} + ts-api-utils@1.3.0(typescript@5.3.3): + dependencies: + typescript: 5.3.3 + ts-api-utils@2.0.1(typescript@5.8.2): dependencies: typescript: 5.8.2 @@ -22240,14 +22955,32 @@ snapshots: dependencies: typescript: 5.8.3 - ts-api-utils@2.1.0(typescript@5.8.2): - dependencies: - typescript: 5.8.2 - ts-case-convert@2.1.0: {} ts-dedent@2.2.0: {} + ts-jest-resolver@2.0.1: + dependencies: + jest-resolve: 29.7.0 + + ts-jest@29.1.2(@babel/core@7.24.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.7))(esbuild@0.19.11)(jest@29.7.0(@types/node@20.11.17))(typescript@5.3.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@20.11.17) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.1 + typescript: 5.3.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.24.7 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.24.7) + esbuild: 0.19.11 + ts-map@1.0.3: {} tsc-alias@1.8.15: @@ -22289,7 +23022,7 @@ snapshots: tsx@4.19.3: dependencies: - esbuild: 0.25.0 + esbuild: 0.25.2 get-tsconfig: 4.9.0 optionalDependencies: fsevents: 2.3.3 @@ -22391,8 +23124,8 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.13 - debug: 4.4.0(supports-color@8.1.1) - dotenv: 16.4.7 + debug: 4.4.0(supports-color@5.5.0) + dotenv: 16.5.0 glob: 10.4.5 reflect-metadata: 0.2.2 sha.js: 2.4.11 @@ -22406,6 +23139,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.3.3: {} + typescript@5.8.2: {} typescript@5.8.3: {} @@ -22434,6 +23169,8 @@ snapshots: undefsafe@2.0.5: {} + undici-types@5.26.5: {} + undici-types@6.20.0: {} undici-types@6.21.0: {} @@ -22587,7 +23324,7 @@ snapshots: vite-node@3.1.1(@types/node@22.14.0)(sass@1.86.3)(terser@5.39.0)(tsx@4.19.3): dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) es-module-lexer: 1.6.0 pathe: 2.0.3 vite: 6.3.1(@types/node@22.14.0)(sass@1.86.3)(terser@5.39.0)(tsx@4.19.3) @@ -22636,7 +23373,7 @@ snapshots: '@vitest/spy': 3.1.1 '@vitest/utils': 3.1.1 chai: 5.2.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 @@ -22674,7 +23411,7 @@ snapshots: vscode-languageclient@9.0.1: dependencies: minimatch: 5.1.2 - semver: 7.7.1 + semver: 7.6.3 vscode-languageserver-protocol: 3.17.5 vscode-languageserver-protocol@3.17.5: @@ -22722,7 +23459,7 @@ snapshots: vue-eslint-parser@10.1.3(eslint@9.22.0): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0(supports-color@5.5.0) eslint: 9.22.0 eslint-scope: 8.3.0 eslint-visitor-keys: 4.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8d4e0f73eb..7e91a8dfb4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ packages: - packages/misskey-js/generator - packages/misskey-reversi - packages/misskey-bubble-game + - packages/misskey-mahjong onlyBuiltDependencies: - '@nestjs/core' - '@parcel/watcher' diff --git a/scripts/clean-all.js b/scripts/clean-all.js index dc391ecfd8..8188a01db4 100644 --- a/scripts/clean-all.js +++ b/scripts/clean-all.js @@ -31,6 +31,9 @@ const fs = require('fs'); fs.rmSync(__dirname + '/../packages/misskey-bubble-game/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/misskey-bubble-game/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/misskey-mahjong/built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/misskey-mahjong/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../node_modules', { recursive: true, force: true }); diff --git a/scripts/clean.js b/scripts/clean.js index 86c19281ea..959c6c869e 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -14,5 +14,6 @@ const fs = require('fs'); fs.rmSync(__dirname + '/../packages/misskey-js/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/misskey-reversi/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/misskey-bubble-game/built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/misskey-mahjong/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../built', { recursive: true, force: true }); })(); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 3f66028bee..2b612438ad 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -34,6 +34,12 @@ await Promise.all([ }), ]); +await execa('pnpm', ['--filter', 'misskey-mahjong', 'build:tsc'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, +}); + execa('pnpm', ['build-pre', '--watch'], { cwd: _dirname + '/../', stdout: process.stdout, @@ -93,3 +99,9 @@ execa('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], { stdout: process.stdout, stderr: process.stderr, }); + +execa('pnpm', ['--filter', 'misskey-mahjong', 'watch'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, +});