diff --git a/Dockerfile b/Dockerfile index a8d3dbcd89..3e54a493d9 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/"] RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output @@ -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/"] RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output @@ -85,10 +87,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 8f4c9d18e4..10ba15408e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9616,6 +9616,288 @@ export interface Locale extends ILocale { */ "disallowIrregularRules": 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; + /** + * 純正九連宝灯 + */ + "pure-churen": string; + /** + * 天和 + */ + "tenho": string; + /** + * 地和 + */ + "chiho": string; + }; + }; "_offlineScreen": { /** * オフライン - サーバーに接続できません diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 5348502425..2f89bb022f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2562,6 +2562,79 @@ _reversi: allowIrregularRules: "変則許可 (完全フリー)" disallowIrregularRules: "変則なし" +_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 c670e232e4..1ded5b3aec 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,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 ee1bf676cb..9099c13c00 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -134,6 +134,7 @@ "mime-types": "2.1.35", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", + "misskey-mahjong": "workspace:*", "ms": "3.0.0-canary.1", "nanoid": "5.0.4", "nested-property": "4.0.0", diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index c31cef36e8..9e3fd287ab 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -67,6 +67,7 @@ import { FanoutTimelineService } from './FanoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.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'; @@ -205,6 +206,7 @@ const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpo const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; 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 }; @@ -344,6 +346,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChannelFollowingService, RegistryApiService, ReversiService, + MahjongService, ChartLoggerService, FederationChart, @@ -479,6 +482,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChannelFollowingService, $RegistryApiService, $ReversiService, + $MahjongService, $ChartLoggerService, $FederationChart, @@ -615,6 +619,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChannelFollowingService, RegistryApiService, ReversiService, + MahjongService, FederationChart, NotesChart, @@ -749,6 +754,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChannelFollowingService, $RegistryApiService, $ReversiService, + $MahjongService, $FederationChart, $NotesChart, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 01dd133ead..d5520339b7 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'; @@ -192,6 +193,52 @@ export interface ReversiGameEventTypes { userId: MiUser['id']; }; } + +export interface MahjongRoomEventTypes { + joined: { + index: number; + user: Packed<'UserLite'>; + }; + changeReadyStates: { + user1: boolean; + user2: boolean; + user3: boolean; + user4: boolean; + }; + started: { + room: Packed<'MahjongRoomDetailed'>; + }; + tsumo: { + house: Mmj.House; + tile: Mmj.Tile; + }; + dahai: { + house: Mmj.House; + tile: Mmj.Tile; + riichi: boolean; + }; + dahaiAndTsumo: { + dahaiHouse: Mmj.House; + dahaiTile: Mmj.Tile; + tsumoTile: Mmj.Tile; + riichi: boolean; + }; + ponned: { + caller: Mmj.House; + callee: Mmj.House; + tile: Mmj.Tile; + }; + kanned: { + caller: Mmj.House; + callee: Mmj.House; + tile: Mmj.Tile; + rinsyan: Mmj.Tile; + }; + ronned: { + }; + tsumoHora: { + }; +} //#endregion // 辞書(interface or type)から{ type, body }ユニオンを定義 @@ -290,6 +337,10 @@ export type GlobalEvents = { name: `reversiGameStream:${MiReversiGame['id']}`; payload: EventUnionFromDictionary>; }; + mahjongRoom: { + name: `mahjongRoomStream:${string}`; + payload: EventUnionFromDictionary>; + }; }; // API event definitions @@ -389,4 +440,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 919f4794a3..6079bb081c 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -80,5 +80,6 @@ export const DI = { userMemosRepository: Symbol('userMemosRepository'), 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 1ce52f4f58..14f0aa6a05 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -40,6 +40,7 @@ import { packedSigninSchema } from '@/models/json-schema/signin.js'; import { packedRoleLiteSchema, packedRoleSchema, packedRolePoliciesSchema } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js'; +import { packedMahjongRoomDetailedSchema } from '@/models/json-schema/mahjong-room.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -83,6 +84,7 @@ export const refs = { RolePolicies: packedRolePoliciesSchema, ReversiGameLite: packedReversiGameLiteSchema, ReversiGameDetailed: packedReversiGameDetailedSchema, + 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..eaef2be891 --- /dev/null +++ b/packages/backend/src/models/MahjongGame.ts @@ -0,0 +1,89 @@ +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 bd447570dd..815beeb8a7 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame } from './_.js'; +import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame, MiMahjongGame } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -411,6 +411,12 @@ const $reversiGamesRepository: Provider = { inject: [DI.db], }; +const $mahjongGamesRepository: Provider = { + provide: DI.mahjongGamesRepository, + useFactory: (db: DataSource) => db.getRepository(MiMahjongGame), + inject: [DI.db], +}; + @Module({ imports: [ ], @@ -482,6 +488,7 @@ const $reversiGamesRepository: Provider = { $userMemosRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, + $mahjongGamesRepository, ], exports: [ $usersRepository, @@ -551,6 +558,7 @@ const $reversiGamesRepository: Provider = { $userMemosRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, + $mahjongGamesRepository, ], }) export class RepositoryModule {} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 43d42d80dd..867400f46e 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -70,6 +70,7 @@ import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiMahjongGame } from '@/models/MahjongGame.js'; import type { Repository } from 'typeorm'; @@ -141,6 +142,7 @@ export { MiUserMemo, MiBubbleGameRecord, MiReversiGame, + MiMahjongGame, }; export type AbuseUserReportsRepository = Repository; @@ -210,3 +212,4 @@ export type FlashLikesRepository = Repository; export type UserMemoRepository = Repository; export type BubbleGameRecordsRepository = Repository; export type ReversiGamesRepository = Repository; +export type MahjongGamesRepository = Repository; 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..d6e4e517dd --- /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: null, + format: 'id', + }, + user2Id: { + type: 'string', + optional: false, nullable: null, + format: 'id', + }, + user3Id: { + type: 'string', + optional: false, nullable: null, + format: 'id', + }, + user4Id: { + type: 'string', + optional: false, nullable: null, + format: 'id', + }, + user1: { + type: 'object', + optional: false, nullable: null, + ref: 'User', + }, + user2: { + type: 'object', + optional: false, nullable: null, + ref: 'User', + }, + user3: { + type: 'object', + optional: false, nullable: null, + ref: 'User', + }, + user4: { + type: 'object', + optional: false, nullable: null, + 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 2d14537bbb..f0282c915b 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -78,6 +78,7 @@ import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiMahjongGame } from '@/models/MahjongGame.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -194,6 +195,7 @@ export const entities = [ MiUserMemo, MiBubbleGameRecord, MiReversiGame, + MiMahjongGame, ...charts, ]; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index f43968d236..cd44058d0d 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -45,6 +45,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.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'; @Module({ imports: [ @@ -82,6 +83,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js RoleTimelineChannelService, ReversiChannelService, ReversiGameChannelService, + MahjongRoomChannelService, HomeTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 8a003725cd..a6ba4a4fa2 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -373,6 +373,9 @@ import * as ep___reversi_invitations from './endpoints/reversi/invitations.js'; import * as ep___reversi_showGame from './endpoints/reversi/show-game.js'; import * as ep___reversi_surrender from './endpoints/reversi/surrender.js'; import * as ep___reversi_verify from './endpoints/reversi/verify.js'; +import * as ep___mahjong_createRoom from './endpoints/mahjong/create-room.js'; +import * as ep___mahjong_joinRoom from './endpoints/mahjong/join-room.js'; +import * as ep___mahjong_showRoom from './endpoints/mahjong/show-room.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import type { Provider } from '@nestjs/common'; @@ -744,6 +747,9 @@ const $reversi_invitations: Provider = { provide: 'ep:reversi/invitations', useC const $reversi_showGame: Provider = { provide: 'ep:reversi/show-game', useClass: ep___reversi_showGame.default }; const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass: ep___reversi_surrender.default }; const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep___reversi_verify.default }; +const $mahjong_createRoom: Provider = { provide: 'ep:mahjong/create-room', useClass: ep___mahjong_createRoom.default }; +const $mahjong_joinRoom: Provider = { provide: 'ep:mahjong/join-room', useClass: ep___mahjong_joinRoom.default }; +const $mahjong_showRoom: Provider = { provide: 'ep:mahjong/show-room', useClass: ep___mahjong_showRoom.default }; @Module({ imports: [ @@ -1119,6 +1125,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $reversi_showGame, $reversi_surrender, $reversi_verify, + $mahjong_createRoom, + $mahjong_joinRoom, + $mahjong_showRoom, ], exports: [ $admin_meta, @@ -1485,6 +1494,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $reversi_showGame, $reversi_surrender, $reversi_verify, + $mahjong_createRoom, + $mahjong_joinRoom, + $mahjong_showRoom, ], }) export class EndpointsModule {} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index e1c8be727e..b580edc30b 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -373,6 +373,9 @@ import * as ep___reversi_invitations from './endpoints/reversi/invitations.js'; import * as ep___reversi_showGame from './endpoints/reversi/show-game.js'; import * as ep___reversi_surrender from './endpoints/reversi/surrender.js'; import * as ep___reversi_verify from './endpoints/reversi/verify.js'; +import * as ep___mahjong_createRoom from './endpoints/mahjong/create-room.js'; +import * as ep___mahjong_joinRoom from './endpoints/mahjong/join-room.js'; +import * as ep___mahjong_showRoom from './endpoints/mahjong/show-room.js'; const eps = [ ['admin/meta', ep___admin_meta], @@ -742,6 +745,9 @@ const eps = [ ['reversi/show-game', ep___reversi_showGame], ['reversi/surrender', ep___reversi_surrender], ['reversi/verify', ep___reversi_verify], + ['mahjong/create-room', ep___mahjong_createRoom], + ['mahjong/join-room', ep___mahjong_joinRoom], + ['mahjong/show-room', ep___mahjong_showRoom], ]; interface IEndpointMetaBase { 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..99a2a3078b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mahjong/cancel-match.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 { ReversiService } from '@/core/ReversiService.js'; + +export const meta = { + requireCredential: true, + + kind: 'write:account', + + errors: { + }, + + res: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // 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 253409259f..840064c146 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -21,6 +21,7 @@ import { HashtagChannelService } from './channels/hashtag.js'; import { RoleTimelineChannelService } from './channels/role-timeline.js'; import { ReversiChannelService } from './channels/reversi.js'; import { ReversiGameChannelService } from './channels/reversi-game.js'; +import { MahjongRoomChannelService } from './channels/mahjong-room.js'; import { type MiChannelService } from './channel.js'; @Injectable() @@ -42,6 +43,7 @@ export class ChannelsService { private adminChannelService: AdminChannelService, private reversiChannelService: ReversiChannelService, private reversiGameChannelService: ReversiGameChannelService, + private mahjongRoomChannelService: MahjongRoomChannelService, ) { } @@ -64,6 +66,7 @@ export class ChannelsService { case 'admin': return this.adminChannelService; 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..353d0b5ba7 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/mahjong-room.ts @@ -0,0 +1,197 @@ +/* + * 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 '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 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/frontend/.eslintrc.cjs b/packages/frontend/.eslintrc.cjs index 20f88dc078..669b57d736 100644 --- a/packages/frontend/.eslintrc.cjs +++ b/packages/frontend/.eslintrc.cjs @@ -22,8 +22,7 @@ module.exports = { }, ], // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため - // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため - 'id-denylist': ['error', 'window', 'e'], + 'id-denylist': ['error', 'window'], 'no-shadow': ['warn'], 'vue/attributes-order': ['error', { 'alphabetical': false, 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 956c643594..ef53d241a5 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -53,6 +53,7 @@ "matter-js": "0.19.0", "mfm-js": "0.24.0", "misskey-bubble-game": "workspace:*", + "misskey-mahjong": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "photoswipe": "5.4.3", diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue index 606d08e33b..c53788b80e 100644 --- a/packages/frontend/src/pages/games.vue +++ b/packages/frontend/src/pages/games.vue @@ -18,6 +18,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..6ca938f73a --- /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..0f71d6ce63 --- /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..36d2eb0387 --- /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..5119e25660 --- /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 eaeeafd499..432c637064 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -549,6 +549,14 @@ const routes: RouteDef[] = [{ 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/scripts/mahjong.ts b/packages/frontend/src/scripts/mahjong.ts new file mode 100644 index 0000000000..b3704fba74 --- /dev/null +++ b/packages/frontend/src/scripts/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 35d112f6ec..de1c8bf2db 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -130,7 +130,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 1dba2a70d3..c0efea0151 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1676,6 +1676,11 @@ declare namespace entities { ReversiSurrenderRequest, ReversiVerifyRequest, ReversiVerifyResponse, + MahjongCreateRoomResponse, + MahjongJoinRoomRequest, + MahjongJoinRoomResponse, + MahjongShowRoomRequest, + MahjongShowRoomResponse, Error_2 as Error, UserLite, UserDetailedNotMeOnly, @@ -1716,7 +1721,8 @@ declare namespace entities { Role, RolePolicies, ReversiGameLite, - ReversiGameDetailed + ReversiGameDetailed, + MahjongRoomDetailed } } export { entities } @@ -2212,6 +2218,24 @@ type IWebhooksShowResponse = operations['i/webhooks/show']['responses']['200'][' // @public (undocumented) type IWebhooksUpdateRequest = operations['i/webhooks/update']['requestBody']['content']['application/json']; +// @public (undocumented) +type MahjongCreateRoomResponse = operations['mahjong/create-room']['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 MeDetailed = components['schemas']['MeDetailed']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index d27413810c..71370c7423 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -4079,5 +4079,38 @@ declare module '../api.js' { 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**: *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>; } } diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 7e5ca9b9ce..97a344024b 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -551,6 +551,11 @@ import type { ReversiSurrenderRequest, ReversiVerifyRequest, ReversiVerifyResponse, + MahjongCreateRoomResponse, + MahjongJoinRoomRequest, + MahjongJoinRoomResponse, + MahjongShowRoomRequest, + MahjongShowRoomResponse, } from './entities.js'; export type Endpoints = { @@ -921,4 +926,7 @@ export type Endpoints = { 'reversi/show-game': { req: ReversiShowGameRequest; res: ReversiShowGameResponse }; 'reversi/surrender': { req: ReversiSurrenderRequest; res: EmptyResponse }; 'reversi/verify': { req: ReversiVerifyRequest; res: ReversiVerifyResponse }; + 'mahjong/create-room': { req: EmptyRequest; res: MahjongCreateRoomResponse }; + 'mahjong/join-room': { req: MahjongJoinRoomRequest; res: MahjongJoinRoomResponse }; + 'mahjong/show-room': { req: MahjongShowRoomRequest; res: MahjongShowRoomResponse }; } diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 9363ef7bcf..4bac47bcf5 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -553,3 +553,8 @@ export type ReversiShowGameResponse = operations['reversi/show-game']['responses export type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json']; export type ReversiVerifyRequest = operations['reversi/verify']['requestBody']['content']['application/json']; export type ReversiVerifyResponse = operations['reversi/verify']['responses']['200']['content']['application/json']; +export type MahjongCreateRoomResponse = operations['mahjong/create-room']['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']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 8d594e61bb..0b88e390eb 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -40,3 +40,4 @@ export type Role = components['schemas']['Role']; export type RolePolicies = components['schemas']['RolePolicies']; export type ReversiGameLite = components['schemas']['ReversiGameLite']; export type ReversiGameDetailed = components['schemas']['ReversiGameDetailed']; +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 94d6673ac5..5a37a063d0 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3530,6 +3530,33 @@ export type paths = { */ post: operations['reversi/verify']; }; + '/mahjong/create-room': { + /** + * mahjong/create-room + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + post: operations['mahjong/create-room']; + }; + '/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']; + }; }; export type webhooks = Record; @@ -4603,6 +4630,39 @@ export type components = { logs: unknown[][]; map: string[]; }; + 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; + /** Format: id */ + user3Id: string; + /** Format: id */ + user4Id: string; + user1: components['schemas']['User']; + user2: components['schemas']['User']; + user3: components['schemas']['User']; + user4: components['schemas']['User']; + user1Ai: boolean; + user2Ai: boolean; + user3Ai: boolean; + user4Ai: boolean; + user1Ready: boolean; + user2Ready: boolean; + user3Ready: boolean; + user4Ready: boolean; + timeLimitForEachTurn: number; + }; }; responses: never; parameters: never; @@ -26185,5 +26245,159 @@ export type operations = { }; }; }; + /** + * 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/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']; + }; + }; + }; + }; }; diff --git a/packages/misskey-mahjong/.eslintignore b/packages/misskey-mahjong/.eslintignore new file mode 100644 index 0000000000..f22128f047 --- /dev/null +++ b/packages/misskey-mahjong/.eslintignore @@ -0,0 +1,7 @@ +node_modules +/built +/coverage +/.eslintrc.js +/jest.config.ts +/test +/test-d diff --git a/packages/misskey-mahjong/.eslintrc.cjs b/packages/misskey-mahjong/.eslintrc.cjs new file mode 100644 index 0000000000..db37a01098 --- /dev/null +++ b/packages/misskey-mahjong/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + extends: [ + '../shared/.eslintrc.js', + ], +}; diff --git a/packages/misskey-mahjong/build.js b/packages/misskey-mahjong/build.js new file mode 100644 index 0000000000..4744dfaf7b --- /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/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..f6b9f79cfb --- /dev/null +++ b/packages/misskey-mahjong/package.json @@ -0,0 +1,50 @@ +{ + "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", + "eslint": "8.56.0", + "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.ts b/packages/misskey-mahjong/src/common.ts new file mode 100644 index 0000000000..18ac16ee32 --- /dev/null +++ b/packages/misskey-mahjong/src/common.ts @@ -0,0 +1,666 @@ +/* + * 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 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 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: [TileId, TileId, TileId]; + from: House; +} | { + type: 'cii'; + tiles: [TileId, TileId, TileId]; + from: House; +} | { + type: 'ankan'; + tiles: [TileId, TileId, TileId, TileId]; +} | { + type: 'minkan'; + tiles: [TileId, TileId, TileId, TileId]; + from: House | null; // null で加槓 +}; + +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[]; +const KOKUSHI_TILES: TileType[] = ['m1', 'm9', 'p1', 'p9', 's1', 's9', 'e', 's', 'w', 'n', 'haku', 'hatsu', 'chun']; + +export function isManzu(tile: T): tile is typeof MANZU_TILES[number] { + return MANZU_TILES.includes(tile); +} + +export function isPinzu(tile: T): tile is typeof PINZU_TILES[number] { + return PINZU_TILES.includes(tile); +} + +export function isSouzu(tile: T): tile is typeof SOUZU_TILES[number] { + return SOUZU_TILES.includes(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 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' && doras.includes(huro.tile)) count += 3; + if (huro.type === 'cii') count += huro.tiles.filter(t => doras.includes(t)).length; + if (huro.type === 'minkan' && doras.includes(huro.tile)) count += 4; + if (huro.type === 'ankan' && doras.includes(huro.tile)) 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, fans: number): Record { + const isParent = house === 'e'; + + const deltas: Record = { + e: 0, + s: 0, + w: 0, + n: 0, + }; + + const point = fanToPoint(fans, 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 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..a11a9764df --- /dev/null +++ b/packages/misskey-mahjong/src/common.yaku.ts @@ -0,0 +1,816 @@ +/* + * 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 } from './common.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; + +export type YakuName = typeof NORMAL_YAKU_NAMES[number] | typeof YAKUMAN_NAMES[number]; + +export type EnvForCalcYaku = { + house: House; + + /** + * 和了る人の手牌(副露牌は含まず、ツモ、ロン牌は含む) + */ + handTiles: TileType[]; + + /** + * 河 + */ + hoTiles: TileType[]; + + /** + * 副露 + */ + huros: ({ + type: 'pon'; + tile: TileType; + } | { + type: 'cii'; + tiles: [TileType, TileType, TileType]; + } | { + type: 'ankan'; + tile: TileType; + } | { + type: 'minkan'; + tile: TileType; + })[]; + + tsumoTile: TileType; + ronTile: TileType; + + /** + * 場風 + */ + fieldWind: House; + + /** + * 自風 + */ + seatWind: House; + + /** + * リーチしたかどうか + */ + riichi: boolean; + + /** + * 一巡目以内かどうか + */ + ippatsu: boolean; +}; + +type YakuDefiniyion = { + name: YakuName; + upper?: YakuName; + fan?: number; + isYakuman?: boolean; + isDoubleYakuman?: boolean; + kuisagari?: boolean; + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => boolean; +}; + +function countTiles(tiles: TileType[], target: TileType): number { + return tiles.filter(t => t === target).length; +} + +export const NORAML_YAKU_DEFINITIONS: YakuDefiniyion[] = [{ + name: 'tsumo', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false; + + return state.tsumoTile != null; + }, +}, { + name: 'riichi', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return state.riichi; + }, +}, { + name: 'ippatsu', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return state.ippatsu; + }, +}, { + name: 'red', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return ( + (countTiles(state.handTiles, 'chun') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'chun' : + huro.type === 'ankan' ? huro.tile === 'chun' : + huro.type === 'minkan' ? huro.tile === 'chun' : + false).length >= 3) + ); + }, +}, { + name: 'white', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return ( + (countTiles(state.handTiles, 'haku') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'haku' : + huro.type === 'ankan' ? huro.tile === 'haku' : + huro.type === 'minkan' ? huro.tile === 'haku' : + false).length >= 3) + ); + }, +}, { + name: 'green', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return ( + (countTiles(state.handTiles, 'hatsu') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'hatsu' : + huro.type === 'ankan' ? huro.tile === 'hatsu' : + huro.type === 'minkan' ? huro.tile === 'hatsu' : + false).length >= 3) + ); + }, +}, { + name: 'field-wind-e', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.fieldWind === 'e' && ( + (countTiles(state.handTiles, 'e') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'e' : + huro.type === 'ankan' ? huro.tile === 'e' : + huro.type === 'minkan' ? huro.tile === 'e' : + false).length >= 3) + ); + }, +}, { + name: 'field-wind-s', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.fieldWind === 's' && ( + (countTiles(state.handTiles, 's') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 's' : + huro.type === 'ankan' ? huro.tile === 's' : + huro.type === 'minkan' ? huro.tile === 's' : + false).length >= 3) + ); + }, +}, { + name: 'seat-wind-e', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.house === 'e' && ( + (countTiles(state.handTiles, 'e') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'e' : + huro.type === 'ankan' ? huro.tile === 'e' : + huro.type === 'minkan' ? huro.tile === 'e' : + false).length >= 3) + ); + }, +}, { + name: 'seat-wind-s', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.house === 's' && ( + (countTiles(state.handTiles, 's') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 's' : + huro.type === 'ankan' ? huro.tile === 's' : + huro.type === 'minkan' ? huro.tile === 's' : + false).length >= 3) + ); + }, +}, { + name: 'seat-wind-w', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.house === 'w' && ( + (countTiles(state.handTiles, 'w') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'w' : + huro.type === 'ankan' ? huro.tile === 'w' : + huro.type === 'minkan' ? huro.tile === 'w' : + false).length >= 3) + ); + }, +}, { + name: 'seat-wind-n', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return state.house === 'n' && ( + (countTiles(state.handTiles, 'n') >= 3) || + (state.huros.filter(huro => + huro.type === 'pon' ? huro.tile === 'n' : + huro.type === 'ankan' ? huro.tile === 'n' : + huro.type === 'minkan' ? huro.tile === 'n' : + false).length >= 3) + ); + }, +}, { + name: 'tanyao', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + return ( + (!state.handTiles.some(t => YAOCHU_TILES.includes(t))) && + (state.huros.filter(huro => + huro.type === 'pon' ? YAOCHU_TILES.includes(huro.tile) : + huro.type === 'ankan' ? YAOCHU_TILES.includes(huro.tile) : + huro.type === 'minkan' ? YAOCHU_TILES.includes(huro.tile) : + huro.type === 'cii' ? huro.tiles.some(t2 => YAOCHU_TILES.includes(t2)) : + false).length === 0) + ); + }, +}, { + name: 'pinfu', + fan: 1, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false; + // 三元牌はダメ + if (state.handTiles.some(t => ['haku', 'hatsu', 'chun'].includes(t))) return false; + + // TODO: 両面待ちかどうか + + // 風牌判定(役牌でなければ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) => { + if (fourMentsuOneJyantou == null) return false; + + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length; + let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length; + let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length; + let charCount = tiles.filter(t => CHAR_TILES.includes(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 => MANZU_TILES.includes(t)).length; + pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length; + souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length; + charCount += huroTiles.filter(t => CHAR_TILES.includes(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) => { + if (fourMentsuOneJyantou == null) return false; + + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length; + let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length; + let souzuCount = tiles.filter(t => SOUZU_TILES.includes(t)).length; + let charCount = tiles.filter(t => CHAR_TILES.includes(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 => MANZU_TILES.includes(t)).length; + pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length; + souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(t)).length; + charCount += huroTiles.filter(t => CHAR_TILES.includes(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 => CALL_HURO_TYPES.includes(huro.type))) return false; + + // 同じ順子が2つあるか? + return fourMentsuOneJyantou.mentsus.some((mentsu) => + fourMentsuOneJyantou.mentsus.filter((mentsu2) => + mentsu2[0] === mentsu[0] && mentsu2[1] === mentsu[1] && mentsu2[2] === mentsu[2]).length >= 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: FourMentsuOneJyantou | null) => { + + }, +}, { + 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)); + + 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: 'chitoitsu', + fan: 2, + isYakuman: false, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + 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: YakuDefiniyion[] = [{ + 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, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + const tiles = state.handTiles; + let manzuCount = tiles.filter(t => MANZU_TILES.includes(t)).length; + let pinzuCount = tiles.filter(t => PINZU_TILES.includes(t)).length; + let souzuCount = tiles.filter(t => SOUZU_TILES.includes(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 => MANZU_TILES.includes(t)).length; + pinzuCount += huroTiles.filter(t => PINZU_TILES.includes(t)).length; + souzuCount += huroTiles.filter(t => SOUZU_TILES.includes(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: 'churen-9', + isYakuman: true, + isDoubleYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + if (fourMentsuOneJyantou == null) return false; + + // 面前じゃないとダメ + if (state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type))) return false; + + const agariTile = state.tsumoTile ?? state.ronTile; + 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 => CALL_HURO_TYPES.includes(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', + isYakuman: true, + calc: (state: EnvForCalcYaku, fourMentsuOneJyantou: FourMentsuOneJyantou | null) => { + return KOKUSHI_TILES.every(t => state.handTiles.includes(t)); + }, +}]; + +export function calcYakus(state: EnvForCalcYaku): YakuName[] { + const oneHeadFourMentsuPatterns: (FourMentsuOneJyantou | null)[] = analyzeFourMentsuOneJyantou(state.handTiles); + if (oneHeadFourMentsuPatterns.length === 0) oneHeadFourMentsuPatterns.push(null); + + const yakumanPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => { + const matchedYakus: YakuDefiniyion[] = []; + for (const yakuDef of YAKUMAN_DEFINITIONS) { + if (yakuDef.upper && matchedYakus.some(yaku => yaku.name === yakuDef.upper)) continue; + const matched = yakuDef.calc(state, fourMentsuOneJyantou); + if (matched) { + matchedYakus.push(yakuDef); + } + } + return matchedYakus; + }).filter(yakus => yakus.length > 0); + + if (yakumanPatterns.length > 0) { + return yakumanPatterns[0].map(yaku => yaku.name); + } + + const yakuPatterns = oneHeadFourMentsuPatterns.map(fourMentsuOneJyantou => { + return NORAML_YAKU_DEFINITIONS.map(yakuDef => { + const result = yakuDef.calc(state, fourMentsuOneJyantou); + return result ? yakuDef : null; + }).filter(yaku => yaku != null) as YakuDefiniyion[]; + }).filter(yakus => yakus.length > 0); + + const isMenzen = state.huros.some(huro => CALL_HURO_TYPES.includes(huro.type)); + + 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 maxYakus.map(yaku => yaku.name); +} diff --git a/packages/misskey-mahjong/src/engine.master.ts b/packages/misskey-mahjong/src/engine.master.ts new file mode 100644 index 0000000000..c2e0669159 --- /dev/null +++ b/packages/misskey-mahjong/src/engine.master.ts @@ -0,0 +1,939 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import CRC32 from 'crc-32'; +import { TileType, House, Huro, TILE_TYPES, YAKU_DEFINITIONS, TileId } from './common.js'; +import * as Common from './common.js'; +import { PlayerState } from './engine.player.js'; + +//#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)).length >= 2); + } + + public tsumo(): TileId { + const tile = this.$state.tiles.pop(); + 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); + return tile; + } +} + +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[]; + }; + riichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + ippatsus: { + 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(): 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: [], + }, + riichis: { + e: false, + s: false, + w: false, + n: false, + }, + ippatsus: { + e: false, + s: false, + w: false, + n: false, + }, + points: { + e: 25000, + s: 25000, + w: 25000, + n: 25000, + }, + turn: 'e', + nextTurnAfterAsking: null, + askings: { + ron: null, + pon: null, + cii: null, + kan: null, + }, + }; + } + + 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; + } + + 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)); + 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]; + tx.$state.huros[house].push({ type: 'minkan', tiles: tiles, from: pon.from }); + + tx.$state.ippatsus.e = false; + tx.$state.ippatsus.s = false; + tx.$state.ippatsus.w = false; + tx.$state.ippatsus.n = false; + + tx.$state.activatedDorasCount++; + + const rinsyan = tx.tsumo(); + + 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]; + tx.$state.huros[house].push({ type: 'ankan', tiles: tiles }); + + tx.$state.ippatsus.e = false; + tx.$state.ippatsus.s = false; + tx.$state.ippatsus.w = false; + tx.$state.ippatsus.n = false; + + tx.$state.activatedDorasCount++; + + const rinsyan = tx.tsumo(); + + tx.$commit(); + + return { + rinsyan, + tiles, + }; + } + + /** + * ツモ和了 + * @param house + */ + public commit_tsumoHora(house: House) { + const tx = this.startTransaction(); + + if (tx.$state.turn !== house) throw new Error('Not your turn'); + + const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ + house: house, + handTiles: tx.handTileTypes[house], + huros: tx.$state.huros[house], + tsumoTile: tx.handTileTypes[house].at(-1)!, + ronTile: null, + riichi: tx.$state.riichis[house], + ippatsu: tx.$state.ippatsus[house], + })); + const doraCount = + Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) + + Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]); + const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; + const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans); + tx.$state.points.e += pointDeltas.e; + tx.$state.points.s += pointDeltas.s; + tx.$state.points.w += pointDeltas.w; + tx.$state.points.n += pointDeltas.n; + console.log('yakus', house, yakus); + + tx.$commit(); + + return { + handTiles: tx.$state.handTiles[house], + tsumoTile: tx.$state.handTiles[house].at(-1)!, + }; + } + + public commit_resolveCallingInterruption(answers: { + pon: boolean; + cii: false | 'x__' | '_x_' | '__x'; + kan: boolean; + ron: House[]; + }) { + 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; + + for (const house of callers) { + const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ + house: house, + handTiles: tx.handTileTypes[house], + huros: tx.$state.huros[house], + tsumoTile: null, + ronTile: tx.hoTileTypes[callee].at(-1)!, + riichi: tx.$state.riichis[house], + ippatsu: tx.$state.ippatsus[house], + })); + const doraCount = + Common.calcOwnedDoraCount(tx.handTileTypes[house], tx.$state.huros[house], tx.doras) + + Common.calcRedDoraCount(tx.$state.handTiles[house], tx.$state.huros[house]); + const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; + const point = Common.fanToPoint(fans, house === 'e'); + tx.$state.points[callee] -= point; + tx.$state.points[house] += point; + console.log('fans point', fans, point); + console.log('yakus', house, yakus); + } + + tx.$commit(); + + return { + type: 'ronned' as const, + callers: ron.callers, + callee: ron.callee, + turn: null, + }; + } 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]; + tx.$state.huros[kan.caller].push({ type: 'minkan', tiles: tiles, from: kan.callee }); + + tx.$state.ippatsus.e = false; + tx.$state.ippatsus.s = false; + tx.$state.ippatsus.w = false; + tx.$state.ippatsus.n = false; + + tx.$state.activatedDorasCount++; + + const rinsyan = tx.tsumo(); + + 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]; + tx.$state.huros[pon.caller].push({ type: 'pon', tiles: tiles, from: pon.callee }); + + tx.$state.ippatsus.e = false; + tx.$state.ippatsus.s = false; + tx.$state.ippatsus.w = false; + tx.$state.ippatsus.n = false; + + 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.$state.ippatsus.e = false; + tx.$state.ippatsus.s = false; + tx.$state.ippatsus.w = false; + tx.$state.ippatsus.n = false; + + 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, + }, + riichis: { + e: this.$state.riichis.e, + s: this.$state.riichis.s, + w: this.$state.riichis.w, + n: this.$state.riichis.n, + }, + ippatsus: { + e: this.$state.ippatsus.e, + s: this.$state.ippatsus.s, + w: this.$state.ippatsus.w, + n: this.$state.ippatsus.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, + }; + } + + public calcCrc32ForUser1(): number { + // TODO + } + + public calcCrc32ForUser2(): number { + // TODO + } + + public calcCrc32ForUser3(): number { + // TODO + } + + public calcCrc32ForUser4(): number { + // TODO + } + + public getState(): MasterState { + return structuredClone(this.$state); + } +} + +function commit_dahai(state: MasterState): MasterState { + +} diff --git a/packages/misskey-mahjong/src/engine.player.ts b/packages/misskey-mahjong/src/engine.player.ts new file mode 100644 index 0000000000..3ca716c9ea --- /dev/null +++ b/packages/misskey-mahjong/src/engine.player.ts @@ -0,0 +1,446 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import CRC32 from 'crc-32'; +import { TileType, House, Huro, TileId, YAKU_DEFINITIONS } from './common.js'; +import * as Common from './common.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[]; + }; + riichis: { + e: boolean; + s: boolean; + w: boolean; + n: boolean; + }; + ippatsus: { + 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; 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)).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 = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ + house: house, + handTiles: handTiles.map(id => $type(id)), + huros: this.state.huros[house], + tsumoTile: $type(tsumoTile), + ronTile: null, + riichi: this.state.riichis[house], + ippatsu: this.state.ippatsus[house], + })); + const doraCount = + Common.calcOwnedDoraCount(handTiles.map(id => $type(id)), this.state.huros[house], this.doras) + + Common.calcRedDoraCount(handTiles, this.state.huros[house]); + const fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; + const pointDeltas = Common.calcTsumoHoraPointDeltas(house, fans); + 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.map(yaku => ({ + name: yaku.name, + fan: yaku.fan, + isYakuman: yaku.isYakuman, + })), + 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 } }, + }; + + for (const house of callers) { + const yakus = YAKU_DEFINITIONS.filter(yaku => yaku.calc({ + house: house, + handTiles: handTiles[house].map(id => $type(id)), + huros: this.state.huros[house], + tsumoTile: null, + ronTile: $type(this.state.hoTiles[callee].at(-1)!), + riichi: this.state.riichis[house], + ippatsu: this.state.ippatsus[house], + })); + 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 fans = yakus.map(yaku => yaku.fan).reduce((a, b) => a + b, 0) + doraCount; + const point = Common.fanToPoint(fans, house === 'e'); + this.state.points[callee] -= point; + this.state.points[house] += point; + resultMap[house].yakus = yakus.map(yaku => ({ name: yaku.name, fan: yaku.fan, isYakuman: yaku.isYakuman })); + 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: 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: 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: 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..8c9d14990b --- /dev/null +++ b/packages/misskey-mahjong/src/serializer.ts @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { TileType } from './common.js'; + +export type Log = { + time: number; + player: 1 | 2 | 3 | 4; + operation: 'dahai'; + tile: string; +}; + +export type SerializedLog = number[]; + +export const TILE_MAP: Record = { + '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, +}; + +export function serializeTile(tile: TileType): number { + 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[]) { + const _logs: number[][] = []; + + 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[]) { + 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: log[3], + }); + break; + //case 1: + // _logs.push({ + // time, + // player: player === 1, + // operation: 'surrender', + // }); + // break; + } + } + + return _logs; +} diff --git a/packages/misskey-mahjong/test/yaku.ts b/packages/misskey-mahjong/test/yaku.ts new file mode 100644 index 0000000000..0eb92893b9 --- /dev/null +++ b/packages/misskey-mahjong/test/yaku.ts @@ -0,0 +1,425 @@ +/* + * 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({ + house: 'e', + handTiles: ['m1', 'm2', 'm3', 'p6', 'p6', 'p6', 's6', 's7', 's8', 'n', 'n', 'n', 'm3', 'm3'], + huros: [], + riichi: true, + }), ['riichi']); + }); + }); + + describe('kokushi', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm9'] , + huros: [], + tumoTiles: 'm9', + }), ['kokushi']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm9', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm3'] , + huros: [], + tumoTiles: 'm3', + }).includes('kokushi'), false); + }); + }); + + describe('kokushi-13', () => { + it('valid', () => { + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm9', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm1'] , + huros: [], + tumoTiles: 'm1', + }), ['kokushi-13']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 's1', 's9', 'p1', 'p9', 'haku', 'hatsu', 'chun', 'n', 'w', 's', 'e', 'm9'] , + huros: [], + tumoTiles: 'm1', + }).includes('kokushi-13'), false); + }); + }); + + describe('suanko', () => { + it('valid',() => { + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'e', 'e'], + huros: [], + tsumoTile: 'chun', + }), ['suanko']); + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: '', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'm2', 'm2', 'e', 'e', 'e'], + huros: [{type: 'pom', tile: 'm1'}], + ronTile: 'e', + }).includes('suanko'), false); + + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'm2', 'm2', 'e', 'e', 'e'], + huros: [], + ronTile: 'e', + }).includes('suanko'), false); + + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: '', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], + huros: [], + tsumoTile: 'e', + }), ['suanko-tanki']); + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['m2', 'm2', 'm2', 'm3', 'm3', 'm3', 'haku', 'haku', 'haku', 'e', 'e'], + huros: [{type: 'ankan', tile: 'm1'}], + tsumoTile: 'e', + }), ['suanko-tanki']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: 'e', + handTiles: ['haku', 'haku', 'haku', 'hatsu', 'hatsu', 'hatsu', 'chun', 'chun', 'chun', 'p2', 'p2', 'p2', 's2', 's2'], + huros: [], + tsumoTile: 's2', + }), ['daisangen']); + + assert.deepStrictEqual(calcYakus({ + house: '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({ + house: 'e', + handTiles: ['haku', 'haku', 'haku', 'chun', 'chun', 'hatsu', 'hatsu', 'hatsu', 'm1', 'm1', 'm1', 'm2', 'm2', 'm2'] , + huros: [], + tumoTiles: 'm2', + }).includes('daisangen'), false); + }); + }); + + describe('tsuiso', () => { + it('valid', () =>{ + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['haku', 'haku', 'haku', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }), ['tsuiso']); + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [{type: 'pon', tile: 'haku'}], + tsumoTile: 's', + }), ['tsuiso']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }).includes('tsuiso'), false); + + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [{type: 'pon', tile: 'm1'}], + tsumoTile: 's', + }).includes('tuiso'), false); + }); + }) + + describe('shosushi', () => { + it('valid', () =>{ + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['m1', 'm1', 'm1', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }), ['shosushi']); + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: '', + handTiles: ['m1', 'm1', 'm1', 'hatsu', 'hatsu', 'hatsu', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }).includes('shosushi'), false); + + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: '', + handTiles: ['m1', 'm1', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's', 's'], + huros: [], + tsumoTile: 's', + }), ['daisushi']); + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['m1', 'm1', 'n', 'n', 'n', 'w', 'w', 'w', 's', 's', 's'], + huros: [{type: 'pon', tile: 'e'}], + tsumoTile: 'e', + }), ['daisushi']); + }); + + it('invalid', () => { + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['m1', 'm1', 'm1', 'n', 'n', 'n', 'e', 'e', 'e', 'w', 'w', 'w', 's', 's'], + huros: [], + tsumoTile: 's', + }).includes('daisushi'), false); + + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: '', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's4', 's6', 's6', 's6', 's8', 's8', 's8', 'hatsu', 'hatsu'], + huros: [], + tsumoTile: 'hatsu', + }), ['ryuiso']); + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: '', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8','haku','haku','haku'], + huros: [], + tsumoTile: 's', + }).includes('ryuiso'), false); + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8'], + huros: [{type: 'pon', tile: 'haku'}], + tsumoTile: 's', + }).includes('ryuiso'), false); + }); + }) + + describe('chinroto', () => { + it('valid', () =>{ + assert.deepStrictEqual(calcYakus({ + house: '', + handTiles: ['m1','m1','m1''m9', 'm9', 'm9', 's1', 's1', 's1', 's9', 's9', 's9', 'p1', 'p1'], + huros: [], + tsumoTile: 'p1', + }), ['chinroto']); + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: '', + handTiles: ['s2', 's2', 's2', 's2', 's3', 's3', 's3', 's3', 's4', 's8', 's8','haku','haku','haku'], + huros: [], + tsumoTile: 's', + }).includes('chinroto'), false); + }); + }) + + describe('sukantsu', () => { + it('valid', () =>{ + assert.deepStrictEqual(calcYakus({ + house: '', + 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({ + house: '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({ + house: '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({ + house: '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({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm1'], + huros: [], + tsumoTile: 'm1', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm2'], + huros: [], + tsumoTile: 'm2', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm3'], + huros: [], + tsumoTile: 'm3', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm4'], + huros: [], + tsumoTile: 'm4', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm5'], + huros: [], + tsumoTile: 'm5', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm6'], + huros: [], + tsumoTile: 'm6', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm7'], + huros: [], + tsumoTile: 'm7', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm8'], + huros: [], + tsumoTile: 'm8', + }), ['churen-9']); + + assert.deepStrictEqual(calcYakus({ + house: '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({ + house: 'e', + handTiles: ['m1', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'm7', 'm8', 'm9', 'm9', 'm9', 'm5'], + huros: [], + tsumoTile: 'm5', + }).includes('churen-9'), false); + }); + }); +}); diff --git a/packages/misskey-mahjong/tsconfig.json b/packages/misskey-mahjong/tsconfig.json new file mode 100644 index 0000000000..da94aefea0 --- /dev/null +++ b/packages/misskey-mahjong/tsconfig.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./built/", + "removeComments": true, + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "typeRoots": [ + "./node_modules/@types" + ], + "lib": [ + "esnext", + "dom" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dab66ba1ac..bbd10e9bd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,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 @@ -787,6 +790,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 @@ -1178,6 +1184,52 @@ importers: specifier: 5.3.3 version: 5.3.3 + 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-plugin-import@2.29.1)(eslint@8.56.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@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': + specifier: 6.18.1 + version: 6.18.1(eslint@8.56.0)(typescript@5.3.3) + eslint: + specifier: 8.56.0 + version: 8.56.0 + 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.23.5)(esbuild@0.19.11)(jest@29.7.0)(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: @@ -7260,6 +7312,13 @@ packages: pretty-format: 29.7.0 dev: true + /@types/jest@29.5.12: + resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + dev: true + /@types/js-yaml@4.0.9: resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} dev: true @@ -8999,6 +9058,13 @@ packages: node-releases: 2.0.14 update-browserslist-db: 1.0.13(browserslist@4.22.2) + /bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + dependencies: + fast-json-stable-stringify: 2.1.0 + dev: true + /bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} dependencies: @@ -13922,7 +13988,6 @@ packages: /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - dev: false /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -14065,6 +14130,10 @@ packages: semver: 7.5.4 dev: true + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + /make-fetch-happen@13.0.0: resolution: {integrity: sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==} engines: {node: ^16.14.0 || >=18.0.0} @@ -18502,6 +18571,47 @@ packages: engines: {node: '>=6.10'} dev: true + /ts-jest-resolver@2.0.1: + resolution: {integrity: sha512-FolE73BqVZCs8/RbLKxC67iaAtKpBWx7PeLKFW2zJQlOf9j851I7JRxSDenri2NFvVH3QP7v3S8q1AmL24Zb9Q==} + dependencies: + jest-resolve: 29.7.0 + dev: true + + /ts-jest@29.1.2(@babel/core@7.23.5)(esbuild@0.19.11)(jest@29.7.0)(typescript@5.3.3): + 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 + dependencies: + '@babel/core': 7.23.5 + bs-logger: 0.2.6 + esbuild: 0.19.11 + 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.5.4 + typescript: 5.3.3 + yargs-parser: 21.1.1 + dev: true + /ts-map@1.0.3: resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==} dev: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 193669e7a4..378cdc84ad 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,4 @@ packages: - 'packages/misskey-js/generator' - 'packages/misskey-reversi' - 'packages/misskey-bubble-game' + - 'packages/misskey-mahjong' diff --git a/scripts/clean-all.js b/scripts/clean-all.js index e9512e2d5a..f30911e8d0 100644 --- a/scripts/clean-all.js +++ b/scripts/clean-all.js @@ -25,6 +25,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 af66c24a8f..5730e30625 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -12,5 +12,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 4ca46a260e..07b854cb10 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -46,6 +46,12 @@ await execa('pnpm', ['--filter', 'misskey-bubble-game', 'build:tsc'], { stderr: process.stderr, }); +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'], { stdout: process.stdout, stderr: process.stderr, }); + +execa('pnpm', ['--filter', 'misskey-mahjong', 'watch'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, +});