Compare commits
67 commits
Author | SHA1 | Date | |
---|---|---|---|
bf818a6656 | |||
f32b11ba12 | |||
6c9f6e8057 | |||
6b16b85203 | |||
4597d5db91 | |||
9c79f5d135 | |||
0e27fa59d4 | |||
4ae591a2c7 | |||
af9ebf7034 | |||
b04a0c99a4 | |||
10a2c16a6d | |||
622fc44645 | |||
3f810a856c | |||
c47203b888 | |||
c99d55e0cb | |||
bb042b46ac | |||
084e9449dc | |||
2af3710757 | |||
894f65f754 | |||
166aeb631e | |||
2d6f9b083f | |||
7a9434414d | |||
b302796e70 | |||
76cdb48a3e | |||
b32022c20c | |||
b785793e41 | |||
bfb6e2f461 | |||
38e3d248fb | |||
be3b2558d1 | |||
d57f20dc84 | |||
00bf57d243 | |||
586a458c7a | |||
2dd886e285 | |||
7cdaa10d46 | |||
9ea29fe84c | |||
054a48c184 | |||
c964c49c58 | |||
10a112489d | |||
859cf75ad3 | |||
3c97164cf2 | |||
8121f8f40f | |||
072928b147 | |||
5af8b5d547 | |||
5e3a805671 | |||
ef14a56a5c | |||
2f0924c85b | |||
4183fec4ab | |||
ce65e9dd69 | |||
d7337e5f81 | |||
547b74c9b2 | |||
d427d24ca4 | |||
668bf9a226 | |||
11404e545e | |||
5f48109230 | |||
dad8430040 | |||
0111b8736a | |||
1ea098f4b4 | |||
366fade8d3 | |||
db7bd0e94e | |||
606c88aa6b | |||
55629f2b39 | |||
ab404d491d | |||
0f2991cbaf | |||
34ed9cb187 | |||
67e6184a75 | |||
2133d0552c | |||
314c31db34 |
|
@ -26,6 +26,7 @@ COPY --link ["packages/sw/package.json", "./packages/sw/"]
|
|||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
|
||||
COPY --link ["packages/misskey-mahjong/package.json", "./packages/misskey-mahjong/"]
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
|
@ -56,6 +57,7 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
|
|||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
|
||||
COPY --link ["packages/misskey-mahjong/package.json", "./packages/misskey-mahjong/"]
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
|
@ -91,10 +93,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 . ./
|
||||
|
|
282
locales/index.d.ts
vendored
|
@ -10016,6 +10016,288 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"useAvatarAsStone": string;
|
||||
};
|
||||
"_mahjong": {
|
||||
/**
|
||||
* 麻雀
|
||||
*/
|
||||
"mahjong": string;
|
||||
/**
|
||||
* ルームに参加
|
||||
*/
|
||||
"joinRoom": string;
|
||||
/**
|
||||
* ルームを作成
|
||||
*/
|
||||
"createRoom": string;
|
||||
/**
|
||||
* 準備完了
|
||||
*/
|
||||
"ready": string;
|
||||
/**
|
||||
* 準備を再開
|
||||
*/
|
||||
"cancelReady": string;
|
||||
/**
|
||||
* 退室
|
||||
*/
|
||||
"leave": string;
|
||||
/**
|
||||
* CPUを追加
|
||||
*/
|
||||
"addCpu": string;
|
||||
/**
|
||||
* 東
|
||||
*/
|
||||
"east": string;
|
||||
/**
|
||||
* 南
|
||||
*/
|
||||
"south": string;
|
||||
/**
|
||||
* 西
|
||||
*/
|
||||
"west": string;
|
||||
/**
|
||||
* 北
|
||||
*/
|
||||
"north": string;
|
||||
/**
|
||||
* ドラ
|
||||
*/
|
||||
"dora": string;
|
||||
/**
|
||||
* 赤ドラ
|
||||
*/
|
||||
"redDora": string;
|
||||
/**
|
||||
* 飜
|
||||
*/
|
||||
"fan": string;
|
||||
"_fanNames": {
|
||||
/**
|
||||
* 満貫
|
||||
*/
|
||||
"mangan": string;
|
||||
/**
|
||||
* 跳満
|
||||
*/
|
||||
"haneman": string;
|
||||
/**
|
||||
* 倍満
|
||||
*/
|
||||
"baiman": string;
|
||||
/**
|
||||
* 三倍満
|
||||
*/
|
||||
"sanbaiman": string;
|
||||
/**
|
||||
* 役満
|
||||
*/
|
||||
"yakuman": string;
|
||||
/**
|
||||
* 数え役満
|
||||
*/
|
||||
"kazoeyakuman": string;
|
||||
};
|
||||
"_yakus": {
|
||||
/**
|
||||
* 立直
|
||||
*/
|
||||
"riichi": string;
|
||||
/**
|
||||
* 一発
|
||||
*/
|
||||
"ippatsu": string;
|
||||
/**
|
||||
* 門前清自摸和
|
||||
*/
|
||||
"tsumo": string;
|
||||
/**
|
||||
* 断么
|
||||
*/
|
||||
"tanyao": string;
|
||||
/**
|
||||
* 平和
|
||||
*/
|
||||
"pinfu": string;
|
||||
/**
|
||||
* 一盃口
|
||||
*/
|
||||
"iipeko": string;
|
||||
/**
|
||||
* 東
|
||||
*/
|
||||
"field-wind-e": string;
|
||||
/**
|
||||
* 南
|
||||
*/
|
||||
"field-wind-s": string;
|
||||
/**
|
||||
* 東
|
||||
*/
|
||||
"seat-wind-e": string;
|
||||
/**
|
||||
* 南
|
||||
*/
|
||||
"seat-wind-s": string;
|
||||
/**
|
||||
* 西
|
||||
*/
|
||||
"seat-wind-w": string;
|
||||
/**
|
||||
* 北
|
||||
*/
|
||||
"seat-wind-n": string;
|
||||
/**
|
||||
* 白
|
||||
*/
|
||||
"white": string;
|
||||
/**
|
||||
* 發
|
||||
*/
|
||||
"green": string;
|
||||
/**
|
||||
* 中
|
||||
*/
|
||||
"red": string;
|
||||
/**
|
||||
* 嶺上開花
|
||||
*/
|
||||
"rinshan": string;
|
||||
/**
|
||||
* 搶槓
|
||||
*/
|
||||
"chankan": string;
|
||||
/**
|
||||
* 海底摸月
|
||||
*/
|
||||
"haitei": string;
|
||||
/**
|
||||
* 河底撈魚
|
||||
*/
|
||||
"hotei": string;
|
||||
/**
|
||||
* 三色同順
|
||||
*/
|
||||
"sanshoku-dojun": string;
|
||||
/**
|
||||
* 三色同刻
|
||||
*/
|
||||
"sanshoku-doko": string;
|
||||
/**
|
||||
* 一気通貫
|
||||
*/
|
||||
"ittsu": string;
|
||||
/**
|
||||
* 混全帯么九
|
||||
*/
|
||||
"chanta": string;
|
||||
/**
|
||||
* 七対子
|
||||
*/
|
||||
"chitoitsu": string;
|
||||
/**
|
||||
* 対々
|
||||
*/
|
||||
"toitoi": string;
|
||||
/**
|
||||
* 三暗刻
|
||||
*/
|
||||
"sananko": string;
|
||||
/**
|
||||
* 混老頭
|
||||
*/
|
||||
"honroto": string;
|
||||
/**
|
||||
* 三槓子
|
||||
*/
|
||||
"sankantsu": string;
|
||||
/**
|
||||
* 小三元
|
||||
*/
|
||||
"shosangen": string;
|
||||
/**
|
||||
* ダブル立直
|
||||
*/
|
||||
"double-riichi": string;
|
||||
/**
|
||||
* 混一色
|
||||
*/
|
||||
"honitsu": string;
|
||||
/**
|
||||
* 清全帯么九
|
||||
*/
|
||||
"junchan": string;
|
||||
/**
|
||||
* ニ盃口
|
||||
*/
|
||||
"ryampeko": string;
|
||||
/**
|
||||
* 清一色
|
||||
*/
|
||||
"chinitsu": string;
|
||||
/**
|
||||
* 国士無双
|
||||
*/
|
||||
"kokushi": string;
|
||||
/**
|
||||
* 国士無双十三面待
|
||||
*/
|
||||
"kokushi-13": string;
|
||||
/**
|
||||
* 四暗刻
|
||||
*/
|
||||
"suanko": string;
|
||||
/**
|
||||
* 四暗刻単騎待
|
||||
*/
|
||||
"suanko-tanki": string;
|
||||
/**
|
||||
* 大三元
|
||||
*/
|
||||
"daisangen": string;
|
||||
/**
|
||||
* 字一色
|
||||
*/
|
||||
"tsuiso": string;
|
||||
/**
|
||||
* 小四喜
|
||||
*/
|
||||
"shosushi": string;
|
||||
/**
|
||||
* 大四喜
|
||||
*/
|
||||
"daisushi": string;
|
||||
/**
|
||||
* 緑一色
|
||||
*/
|
||||
"ryuiso": string;
|
||||
/**
|
||||
* 清老頭
|
||||
*/
|
||||
"chinroto": string;
|
||||
/**
|
||||
* 四槓子
|
||||
*/
|
||||
"sukantsu": string;
|
||||
/**
|
||||
* 九蓮宝燈
|
||||
*/
|
||||
"churen": string;
|
||||
/**
|
||||
* 九連宝灯九面待
|
||||
*/
|
||||
"churen-9": string;
|
||||
/**
|
||||
* 天和
|
||||
*/
|
||||
"tenho": string;
|
||||
/**
|
||||
* 地和
|
||||
*/
|
||||
"chiho": string;
|
||||
};
|
||||
};
|
||||
"_offlineScreen": {
|
||||
/**
|
||||
* オフライン - サーバーに接続できません
|
||||
|
|
|
@ -2668,6 +2668,79 @@ _reversi:
|
|||
showBoardLabels: "盤面に行・列番号を表示"
|
||||
useAvatarAsStone: "石をアイコンにする"
|
||||
|
||||
_mahjong:
|
||||
mahjong: "麻雀"
|
||||
joinRoom: "ルームに参加"
|
||||
createRoom: "ルームを作成"
|
||||
ready: "準備完了"
|
||||
cancelReady: "準備を再開"
|
||||
leave: "退室"
|
||||
addCpu: "CPUを追加"
|
||||
east: "東"
|
||||
south: "南"
|
||||
west: "西"
|
||||
north: "北"
|
||||
dora: "ドラ"
|
||||
redDora: "赤ドラ"
|
||||
fan: "飜"
|
||||
_fanNames:
|
||||
mangan: "満貫"
|
||||
haneman: "跳満"
|
||||
baiman: "倍満"
|
||||
sanbaiman: "三倍満"
|
||||
yakuman: "役満"
|
||||
kazoeyakuman: "数え役満"
|
||||
_yakus:
|
||||
"riichi": "立直"
|
||||
"ippatsu": "一発"
|
||||
"tsumo": "門前清自摸和"
|
||||
"tanyao": "断么"
|
||||
"pinfu": "平和"
|
||||
"iipeko": "一盃口"
|
||||
"field-wind-e": "東"
|
||||
"field-wind-s": "南"
|
||||
"seat-wind-e": "東"
|
||||
"seat-wind-s": "南"
|
||||
"seat-wind-w": "西"
|
||||
"seat-wind-n": "北"
|
||||
"white": "白"
|
||||
"green": "發"
|
||||
"red": "中"
|
||||
"rinshan": "嶺上開花"
|
||||
"chankan": "搶槓"
|
||||
"haitei": "海底摸月"
|
||||
"hotei": "河底撈魚"
|
||||
"sanshoku-dojun": "三色同順"
|
||||
"sanshoku-doko": "三色同刻"
|
||||
"ittsu": "一気通貫"
|
||||
"chanta": "混全帯么九"
|
||||
"chitoitsu": "七対子"
|
||||
"toitoi": "対々"
|
||||
"sananko": "三暗刻"
|
||||
"honroto": "混老頭"
|
||||
"sankantsu": "三槓子"
|
||||
"shosangen": "小三元"
|
||||
"double-riichi": "ダブル立直"
|
||||
"honitsu": "混一色"
|
||||
"junchan": "清全帯么九"
|
||||
"ryampeko": "ニ盃口"
|
||||
"chinitsu": "清一色"
|
||||
"kokushi": "国士無双"
|
||||
"kokushi-13": "国士無双十三面待"
|
||||
"suanko": "四暗刻"
|
||||
"suanko-tanki": "四暗刻単騎待"
|
||||
"daisangen": "大三元"
|
||||
"tsuiso": "字一色"
|
||||
"shosushi": "小四喜"
|
||||
"daisushi": "大四喜"
|
||||
"ryuiso": "緑一色"
|
||||
"chinroto": "清老頭"
|
||||
"sukantsu": "四槓子"
|
||||
"churen": "九蓮宝燈"
|
||||
"churen-9": "九連宝灯九面待"
|
||||
"tenho": "天和"
|
||||
"chiho": "地和"
|
||||
|
||||
_offlineScreen:
|
||||
title: "オフライン - サーバーに接続できません"
|
||||
header: "サーバーに接続できません"
|
||||
|
|
|
@ -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": {
|
||||
|
|
24
packages/backend/migration/1706234054207-mahjong.js
Normal file
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -138,6 +138,7 @@
|
|||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"misskey-mahjong": "workspace:*",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"nanoid": "5.0.7",
|
||||
"nested-property": "4.0.0",
|
||||
|
|
|
@ -76,6 +76,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';
|
||||
|
@ -221,6 +222,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 };
|
||||
|
@ -369,6 +371,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ChannelFollowingService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
MahjongService,
|
||||
|
||||
ChartLoggerService,
|
||||
FederationChart,
|
||||
|
@ -513,6 +516,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ChannelFollowingService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$MahjongService,
|
||||
|
||||
$ChartLoggerService,
|
||||
$FederationChart,
|
||||
|
@ -658,6 +662,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ChannelFollowingService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
MahjongService,
|
||||
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
|
@ -801,6 +806,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ChannelFollowingService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$MahjongService,
|
||||
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
|
|
|
@ -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';
|
||||
|
@ -194,6 +195,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 }ユニオンを定義
|
||||
|
@ -303,6 +350,10 @@ export type GlobalEvents = {
|
|||
name: `reversiGameStream:${MiReversiGame['id']}`;
|
||||
payload: EventTypesToEventPayload<ReversiGameEventTypes>;
|
||||
};
|
||||
mahjongRoom: {
|
||||
name: `mahjongRoomStream:${string}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<MahjongRoomEventTypes>>;
|
||||
};
|
||||
};
|
||||
|
||||
// API event definitions
|
||||
|
@ -402,4 +453,9 @@ export class GlobalEventService {
|
|||
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
|
||||
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishMahjongRoomStream<K extends keyof MahjongRoomEventTypes>(roomId: string, type: K, value?: MahjongRoomEventTypes[K]): void {
|
||||
this.publish(`mahjongRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
}
|
||||
|
|
734
packages/backend/src/core/MahjongService.ts
Normal file
|
@ -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<Room> {
|
||||
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<Room | null> {
|
||||
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<Room | null> {
|
||||
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<Room | null> {
|
||||
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<Room | null> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -82,5 +82,6 @@ export const DI = {
|
|||
userMemosRepository: Symbol('userMemosRepository'),
|
||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||
mahjongGamesRepository: Symbol('mahjongGamesRepository'),
|
||||
//#endregion
|
||||
};
|
||||
|
|
|
@ -59,6 +59,7 @@ import {
|
|||
} from '@/models/json-schema/meta.js';
|
||||
import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js';
|
||||
import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js';
|
||||
import { packedMahjongRoomDetailedSchema } from '@/models/json-schema/mahjong-room.js';
|
||||
|
||||
export const refs = {
|
||||
UserLite: packedUserLiteSchema,
|
||||
|
@ -115,6 +116,7 @@ export const refs = {
|
|||
MetaDetailed: packedMetaDetailedSchema,
|
||||
SystemWebhook: packedSystemWebhookSchema,
|
||||
AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema,
|
||||
MahjongRoomDetailed: packedMahjongRoomDetailedSchema,
|
||||
};
|
||||
|
||||
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
|
||||
|
|
89
packages/backend/src/models/MahjongGame.ts
Normal file
|
@ -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[][];
|
||||
}
|
|
@ -77,7 +77,8 @@ import {
|
|||
MiUserProfile,
|
||||
MiUserPublickey,
|
||||
MiUserSecurityKey,
|
||||
MiWebhook
|
||||
MiWebhook,
|
||||
MiMahjongGame,
|
||||
} from './_.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
|
@ -495,6 +496,12 @@ const $reversiGamesRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $mahjongGamesRepository: Provider = {
|
||||
provide: DI.mahjongGamesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiMahjongGame),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
|
@ -567,6 +574,7 @@ const $reversiGamesRepository: Provider = {
|
|||
$userMemosRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$mahjongGamesRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
|
@ -638,6 +646,7 @@ const $reversiGamesRepository: Provider = {
|
|||
$userMemosRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$mahjongGamesRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {
|
||||
|
|
|
@ -79,6 +79,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 { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
||||
|
||||
export interface MiRepository<T extends ObjectLiteral> {
|
||||
|
@ -194,6 +195,7 @@ export {
|
|||
MiUserMemo,
|
||||
MiBubbleGameRecord,
|
||||
MiReversiGame,
|
||||
MiMahjongGame,
|
||||
};
|
||||
|
||||
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
|
||||
|
@ -265,3 +267,4 @@ export type FlashLikesRepository = Repository<MiFlashLike> & MiRepository<MiFlas
|
|||
export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMemo>;
|
||||
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
|
||||
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
|
||||
export type MahjongGamesRepository = Repository<MiMahjongGame> & MiRepository<MiMahjongGame>;
|
||||
|
|
114
packages/backend/src/models/json-schema/mahjong-room.ts
Normal file
|
@ -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;
|
|
@ -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';
|
||||
|
@ -198,6 +199,7 @@ export const entities = [
|
|||
MiUserMemo,
|
||||
MiBubbleGameRecord,
|
||||
MiReversiGame,
|
||||
MiMahjongGame,
|
||||
...charts,
|
||||
];
|
||||
|
||||
|
|
|
@ -46,6 +46,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: [
|
||||
|
@ -84,6 +85,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
|
|||
RoleTimelineChannelService,
|
||||
ReversiChannelService,
|
||||
ReversiGameChannelService,
|
||||
MahjongRoomChannelService,
|
||||
HomeTimelineChannelService,
|
||||
HybridTimelineChannelService,
|
||||
LocalTimelineChannelService,
|
||||
|
|
|
@ -385,6 +385,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';
|
||||
|
@ -768,6 +771,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: [
|
||||
|
@ -1155,6 +1161,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,
|
||||
|
@ -1534,6 +1543,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 {}
|
||||
|
|
|
@ -391,6 +391,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],
|
||||
|
@ -772,6 +775,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 {
|
||||
|
|
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
64
packages/backend/src/server/api/endpoints/mahjong/games.ts
Normal file
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
64
packages/backend/src/server/api/endpoints/mahjong/verify.ts
Normal file
|
@ -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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private reversiService: ReversiService,
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const game = await this.reversiService.checkCrc(ps.gameId, ps.crc32);
|
||||
if (game) {
|
||||
return {
|
||||
desynced: true,
|
||||
game: await this.reversiGameEntityService.packDetail(game),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
desynced: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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}`);
|
||||
|
|
197
packages/backend/src/server/api/stream/channels/mahjong-room.ts
Normal file
|
@ -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<true> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
BIN
packages/frontend/assets/mahjong/99.png
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
packages/frontend/assets/mahjong/bg.jpg
Normal file
After Width: | Height: | Size: 177 KiB |
BIN
packages/frontend/assets/mahjong/cii.png
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
packages/frontend/assets/mahjong/dahai.mp3
Normal file
BIN
packages/frontend/assets/mahjong/kaisi.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
packages/frontend/assets/mahjong/kan.png
Normal file
After Width: | Height: | Size: 55 KiB |
BIN
packages/frontend/assets/mahjong/logo.png
Normal file
After Width: | Height: | Size: 352 KiB |
BIN
packages/frontend/assets/mahjong/pon.png
Normal file
After Width: | Height: | Size: 55 KiB |
BIN
packages/frontend/assets/mahjong/putted-tile-1.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
packages/frontend/assets/mahjong/putted-tile-2.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
packages/frontend/assets/mahjong/putted-tile-3.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
packages/frontend/assets/mahjong/putted-tile-4.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
packages/frontend/assets/mahjong/putted-tile-5.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
packages/frontend/assets/mahjong/riichi.png
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
packages/frontend/assets/mahjong/ron.png
Normal file
After Width: | Height: | Size: 49 KiB |
BIN
packages/frontend/assets/mahjong/ryuukyoku.png
Normal file
After Width: | Height: | Size: 72 KiB |
BIN
packages/frontend/assets/mahjong/tile-back.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
packages/frontend/assets/mahjong/tile-side.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
packages/frontend/assets/mahjong/tiles/chun.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
packages/frontend/assets/mahjong/tiles/e.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
packages/frontend/assets/mahjong/tiles/haku.png
Normal file
After Width: | Height: | Size: 2 KiB |
BIN
packages/frontend/assets/mahjong/tiles/hatsu.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m1.png
Normal file
After Width: | Height: | Size: 6.2 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m2.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m3.png
Normal file
After Width: | Height: | Size: 7 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m4.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m5.png
Normal file
After Width: | Height: | Size: 7.9 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m5r.png
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m6.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m7.png
Normal file
After Width: | Height: | Size: 6.9 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m8.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
packages/frontend/assets/mahjong/tiles/m9.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
packages/frontend/assets/mahjong/tiles/n.png
Normal file
After Width: | Height: | Size: 5.6 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p1.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p2.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p3.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p4.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p5.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p5r.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p6.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p7.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p8.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
packages/frontend/assets/mahjong/tiles/p9.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s1.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s2.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s3.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s4.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s5.png
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s5r.png
Normal file
After Width: | Height: | Size: 8.8 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s6.png
Normal file
After Width: | Height: | Size: 9.9 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s7.png
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s8.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
packages/frontend/assets/mahjong/tiles/s9.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
packages/frontend/assets/mahjong/tiles/w.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
packages/frontend/assets/mahjong/tsumo.png
Normal file
After Width: | Height: | Size: 57 KiB |
|
@ -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.4",
|
||||
|
|
|
@ -18,6 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
||||
</MkA>
|
||||
</div>
|
||||
<div class="_panel">
|
||||
<MkA to="/mahjong">
|
||||
<img src="/client-assets/mahjong/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
||||
</MkA>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
|
|
184
packages/frontend/src/pages/mahjong/hand-tiles.vue
Normal file
|
@ -0,0 +1,184 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[$style.root]">
|
||||
<div
|
||||
v-for="tile in Mmj.sortTiles(separateLast ? tiles.slice(0, tiles.length - 1) : tiles)"
|
||||
:class="[$style.tile, { [$style.tileNonSelectable]: selectableTiles != null && !selectableTiles.includes(mj$type(tile)), [$style.tileDora]: doras.includes(mj$type(tile)) }]"
|
||||
@click="chooseTile(tile, $event)"
|
||||
>
|
||||
<div :class="$style.tileInner">
|
||||
<div :class="$style.tileBg1"></div>
|
||||
<div :class="$style.tileBg2"></div>
|
||||
<div :class="$style.tileBg3"></div>
|
||||
<img :src="`/client-assets/mahjong/tiles/${mj$(tile).red ? mj$type(tile) + 'r' : mj$type(tile)}.png`" :class="$style.tileFg1"/>
|
||||
<div :class="$style.tileFg2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="separateLast"
|
||||
style="display: inline-block; margin-left: 5px;"
|
||||
:class="[$style.tile, { [$style.tileNonSelectable]: selectableTiles != null && !selectableTiles.includes(mj$type(tiles.at(-1))), [$style.tileDora]: doras.includes(mj$type(tiles.at(-1))) }]"
|
||||
@click="chooseTile(tiles.at(-1), $event)"
|
||||
>
|
||||
<div :class="$style.tileInner">
|
||||
<div :class="$style.tileBg1"></div>
|
||||
<div :class="$style.tileBg2"></div>
|
||||
<div :class="$style.tileBg3"></div>
|
||||
<img :src="`/client-assets/mahjong/tiles/${mj$(tiles.at(-1)).red ? mj$type(tiles.at(-1)) + 'r' : mj$type(tiles.at(-1))}.png`" :class="$style.tileFg1"/>
|
||||
<div :class="$style.tileFg2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
|
||||
//#region syntax suger
|
||||
function mj$(tid: Mmj.TileId): Mmj.TileInstance {
|
||||
return Mmj.findTileByIdOrFail(tid);
|
||||
}
|
||||
|
||||
function mj$type(tid: Mmj.TileId): Mmj.TileType {
|
||||
return mj$(tid).t;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const props = defineProps<{
|
||||
tiles: Mmj.TileId[];
|
||||
doras: Mmj.TileType[];
|
||||
selectableTiles: Mmj.TileType[] | null;
|
||||
separateLast: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'choose', tile: Mmj.TileId): void;
|
||||
}>();
|
||||
|
||||
function chooseTile(tile: Mmj.TileId, event: MouseEvent) {
|
||||
if (props.selectableTiles != null && !props.selectableTiles.includes(mj$type(tile))) return;
|
||||
emit('choose', tile);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@keyframes shine {
|
||||
0% { translate: -20%; }
|
||||
100% { translate: -70%; }
|
||||
}
|
||||
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
position: relative;
|
||||
width: 35px;
|
||||
aspect-ratio: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tileInner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: clip;
|
||||
border-radius: 4px;
|
||||
transition: translate 0.1s ease;
|
||||
}
|
||||
.tile:hover > .tileInner {
|
||||
translate: 0 -10px;
|
||||
}
|
||||
.tileNonSelectable {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tileDora > .tileInner {
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
rotate: -45deg;
|
||||
translate: -30px;
|
||||
background: #ffffffee;
|
||||
animation: shine 2s infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tileBg1 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: #E38A45;
|
||||
}
|
||||
.tileBg2 {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 95%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: #DFDEDD;
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 78%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 6%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: linear-gradient(0deg, #fff 0%, #fff0 100%);
|
||||
}
|
||||
}
|
||||
.tileBg3 {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 75%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
}
|
||||
.tileFg1 {
|
||||
position: absolute;
|
||||
bottom: 5%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 65%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.tileFg2 {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 1px #000 inset;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
46
packages/frontend/src/pages/mahjong/huro.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="huro.type === 'ankan'" :class="[$style.root]">
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[0])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[1])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[2])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[3])" :variation="variation" :doras="doras"/>
|
||||
</div>
|
||||
<div v-else-if="huro.type === 'minkan'" :class="[$style.root]">
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[0])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[1])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[2])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[3])" :variation="variation" :doras="doras"/>
|
||||
</div>
|
||||
<div v-else-if="huro.type === 'cii'" :class="[$style.root]">
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[0])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[1])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[2])" :variation="variation" :doras="doras"/>
|
||||
</div>
|
||||
<div v-else :class="[$style.root]">
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[0])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[1])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[2])" :variation="variation" :doras="doras"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
import XTile from './tile.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
huro: Mmj.Huro;
|
||||
variation: string;
|
||||
doras: Mmj.TileType[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
}
|
||||
</style>
|
166
packages/frontend/src/pages/mahjong/index.vue
Normal file
|
@ -0,0 +1,166 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkSpacer :contentMax="600">
|
||||
<div class="_gaps">
|
||||
<div>
|
||||
<img src="/client-assets/mahjong/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
||||
</div>
|
||||
|
||||
<div class="_panel _gaps" style="padding: 16px;">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton primary gradate rounded @click="joinRoom">{{ i18n.ts._mahjong.joinRoom }}</MkButton>
|
||||
<MkButton primary gradate rounded @click="createRoom">{{ i18n.ts._mahjong.createRoom }}</MkButton>
|
||||
</div>
|
||||
<div style="font-size: 90%; opacity: 0.7; text-align: center;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/account.js';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
import * as os from '@/os.js';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
|
||||
const myGamesPagination = {
|
||||
endpoint: 'mahjong/games' as const,
|
||||
limit: 10,
|
||||
params: {
|
||||
my: true,
|
||||
},
|
||||
};
|
||||
|
||||
const gamesPagination = {
|
||||
endpoint: 'mahjong/games' as const,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const invitations = ref<Misskey.entities.UserLite[]>([]);
|
||||
const matchingUser = ref<Misskey.entities.UserLite | null>(null);
|
||||
const matchingAny = ref<boolean>(false);
|
||||
const noIrregularRules = ref<boolean>(false);
|
||||
|
||||
async function joinRoom() {
|
||||
const { canceled, result } = await os.inputText({
|
||||
title: 'roomId',
|
||||
});
|
||||
if (canceled) return;
|
||||
const room = await misskeyApi('mahjong/join-room', {
|
||||
roomId: result,
|
||||
});
|
||||
router.push(`/mahjong/g/${room.id}`);
|
||||
}
|
||||
|
||||
async function createRoom(ev: MouseEvent) {
|
||||
const room = await misskeyApi('mahjong/create-room', {
|
||||
});
|
||||
router.push(`/mahjong/g/${room.id}`);
|
||||
}
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: i18n.ts._mahjong.mahjong,
|
||||
icon: 'ti ti-device-gamepad',
|
||||
})));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@keyframes blink {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
.invitation {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
line-height: 32px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gamePreviews {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-gap: var(--margin);
|
||||
}
|
||||
|
||||
.gamePreview {
|
||||
font-size: 90%;
|
||||
border-radius: 8px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.gamePreviewActive {
|
||||
box-shadow: inset 0 0 8px 0px var(--accent);
|
||||
}
|
||||
|
||||
.gamePreviewWaiting {
|
||||
box-shadow: inset 0 0 8px 0px var(--warn);
|
||||
}
|
||||
|
||||
.gamePreviewPlayers {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.gamePreviewPlayersAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
&:first-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.gamePreviewFooter {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.gamePreviewStatusActive {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
|
||||
.gamePreviewStatusWaiting {
|
||||
color: var(--warn);
|
||||
font-weight: bold;
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
|
||||
.waitingScreen {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.waitingScreenTitle {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
</style>
|
1214
packages/frontend/src/pages/mahjong/room.game.vue
Normal file
165
packages/frontend/src/pages/mahjong/room.setting.vue
Normal file
|
@ -0,0 +1,165 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<MkSpacer :contentMax="600">
|
||||
<div class="_gaps">
|
||||
<div class="_panel">
|
||||
<MkAvatar v-if="room.user1" :user="room.user1" :class="$style.userAvatar"/>
|
||||
<div v-else-if="room.user1Ai">AI</div>
|
||||
<div v-if="room.user1Ready">OK</div>
|
||||
</div>
|
||||
<div class="_panel">
|
||||
<MkAvatar v-if="room.user2" :user="room.user2" :class="$style.userAvatar"/>
|
||||
<div v-else-if="room.user2Ai">AI</div>
|
||||
<div v-if="room.user2Ready">OK</div>
|
||||
</div>
|
||||
<div class="_panel">
|
||||
<MkAvatar v-if="room.user3" :user="room.user3" :class="$style.userAvatar"/>
|
||||
<div v-else-if="room.user3Ai">AI</div>
|
||||
<div v-if="room.user3Ready">OK</div>
|
||||
</div>
|
||||
<div class="_panel">
|
||||
<MkAvatar v-if="room.user4" :user="room.user4" :class="$style.userAvatar"/>
|
||||
<div v-else-if="room.user4Ai">AI</div>
|
||||
<div v-if="room.user4Ready">OK</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<MkButton rounded primary @click="addCpu">{{ i18n.ts._mahjong.addCpu }}</MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
|
||||
<div style="text-align: center;" class="_gaps_s">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded danger @click="leave">{{ i18n.ts._mahjong.leave }}</MkButton>
|
||||
<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._mahjong.ready }}</MkButton>
|
||||
<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._mahjong.cancelReady }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
room: Misskey.entities.MahjongRoomDetailed;
|
||||
connection: Misskey.ChannelConnection;
|
||||
}>();
|
||||
|
||||
const room = ref<Misskey.entities.MahjongRoomDetailed>(deepClone(props.room));
|
||||
|
||||
const isReady = computed(() => {
|
||||
if (room.value.user1Id === $i.id && room.value.user1Ready) return true;
|
||||
if (room.value.user2Id === $i.id && room.value.user2Ready) return true;
|
||||
if (room.value.user3Id === $i.id && room.value.user3Ready) return true;
|
||||
if (room.value.user4Id === $i.id && room.value.user4Ready) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
async function leave() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.areYouSure,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
props.connection.send('leave', {});
|
||||
|
||||
router.push('/mahjong');
|
||||
}
|
||||
|
||||
function ready() {
|
||||
props.connection.send('ready', true);
|
||||
}
|
||||
|
||||
function unready() {
|
||||
props.connection.send('ready', false);
|
||||
}
|
||||
|
||||
function addCpu() {
|
||||
props.connection.send('addAi', {});
|
||||
}
|
||||
|
||||
function onChangeReadyStates(states) {
|
||||
room.value.user1Ready = states.user1;
|
||||
room.value.user2Ready = states.user2;
|
||||
room.value.user3Ready = states.user3;
|
||||
room.value.user4Ready = states.user4;
|
||||
}
|
||||
|
||||
function onJoined(x) {
|
||||
switch (x.index) {
|
||||
case 1:
|
||||
room.value.user1 = x.user;
|
||||
room.value.user1Ai = x.user == null;
|
||||
room.value.user1Ready = room.value.user1Ai;
|
||||
break;
|
||||
case 2:
|
||||
room.value.user2 = x.user;
|
||||
room.value.user2Ai = x.user == null;
|
||||
room.value.user2Ready = room.value.user2Ai;
|
||||
break;
|
||||
case 3:
|
||||
room.value.user3 = x.user;
|
||||
room.value.user3Ai = x.user == null;
|
||||
room.value.user3Ready = room.value.user3Ai;
|
||||
break;
|
||||
case 4:
|
||||
room.value.user4 = x.user;
|
||||
room.value.user4Ai = x.user == null;
|
||||
room.value.user4Ready = room.value.user4Ai;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
props.connection.on('changeReadyStates', onChangeReadyStates);
|
||||
props.connection.on('joined', onJoined);
|
||||
|
||||
onUnmounted(() => {
|
||||
props.connection.off('changeReadyStates', onChangeReadyStates);
|
||||
props.connection.off('joined', onJoined);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.userAvatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
background: var(--acrylicBg);
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
</style>
|
113
packages/frontend/src/pages/mahjong/room.vue
Normal file
|
@ -0,0 +1,113 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="room == null || (!room.isEnded && connection == null)"><MkLoading/></div>
|
||||
<RoomSetting v-else-if="!room.isStarted" :room="room" :connection="connection!"/>
|
||||
<RoomGame v-else :room="room" :connection="connection"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import RoomSetting from './room.setting.vue';
|
||||
import RoomGame from './room.game.vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
roomId: string;
|
||||
}>();
|
||||
|
||||
const room = shallowRef<Misskey.entities.MahjongRoomDetailed | null>(null);
|
||||
const connection = shallowRef<Misskey.ChannelConnection | null>(null);
|
||||
const shareWhenStart = ref(false);
|
||||
|
||||
watch(() => props.roomId, () => {
|
||||
fetchGame();
|
||||
});
|
||||
|
||||
function start(_room: Misskey.entities.MahjongRoomDetailed) {
|
||||
if (room.value?.isStarted) return;
|
||||
|
||||
room.value = _room;
|
||||
}
|
||||
|
||||
async function fetchGame() {
|
||||
const _room = await misskeyApi('mahjong/show-room', {
|
||||
roomId: props.roomId,
|
||||
});
|
||||
|
||||
room.value = _room;
|
||||
shareWhenStart.value = false;
|
||||
|
||||
if (connection.value) {
|
||||
connection.value.dispose();
|
||||
}
|
||||
if (!room.value.isEnded) {
|
||||
connection.value = useStream().useChannel('mahjongRoom', {
|
||||
roomId: room.value.id,
|
||||
});
|
||||
connection.value.on('started', x => {
|
||||
start(x.room);
|
||||
});
|
||||
connection.value.on('canceled', x => {
|
||||
connection.value?.dispose();
|
||||
|
||||
if (x.userId !== $i.id) {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
text: i18n.ts._mahjong.roomCanceled,
|
||||
});
|
||||
router.push('/mahjong');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 通信を取りこぼした場合の救済
|
||||
useInterval(async () => {
|
||||
if (room.value == null) return;
|
||||
if (room.value.isStarted) return;
|
||||
|
||||
const _room = await misskeyApi('mahjong/show-room', {
|
||||
roomId: props.roomId,
|
||||
});
|
||||
|
||||
if (_room.isStarted) {
|
||||
start(_room);
|
||||
} else {
|
||||
room.value = _room;
|
||||
}
|
||||
}, 1000 * 10, {
|
||||
immediate: false,
|
||||
afterMounted: true,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchGame();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (connection.value) {
|
||||
connection.value.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: i18n.ts._mahjong.mahjong,
|
||||
icon: 'ti ti-device-roompad',
|
||||
})));
|
||||
</script>
|
78
packages/frontend/src/pages/mahjong/tile.vue
Normal file
|
@ -0,0 +1,78 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[$style.root, { [$style.h]: ['3', '4', '5'].includes(variation), [$style.v]: ['1', '2'].includes(variation), [$style.isDora]: isDora }]">
|
||||
<img :src="`/client-assets/mahjong/putted-tile-${variation}.png`" :class="$style.bg"/>
|
||||
<img :src="`/client-assets/mahjong/tiles/${tile.red ? tile.t + 'r' : tile.t}.png`" :class="$style.fg"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
|
||||
const props = defineProps<{
|
||||
tile: Mmj.TileInstance;
|
||||
variation: string;
|
||||
doras: Mmj.TileType[];
|
||||
}>();
|
||||
|
||||
const isDora = computed(() => props.doras.includes(props.tile.t));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@keyframes shine {
|
||||
0% { translate: -30px; }
|
||||
100% { translate: -130px; }
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: -17px;
|
||||
}
|
||||
.h {
|
||||
margin: -14px -19px -5px;
|
||||
}
|
||||
.v {
|
||||
margin: -14px -18px -11px;
|
||||
}
|
||||
.bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.fg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
width: 53%;
|
||||
height: 53%;
|
||||
object-fit: contain;
|
||||
}
|
||||
/*
|
||||
.isDora {
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
rotate: -45deg;
|
||||
translate: -30px;
|
||||
background: #ffffffee;
|
||||
animation: shine 2s infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
}*/
|
||||
</style>
|
|
@ -576,6 +576,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')),
|
||||
|
|
83
packages/frontend/src/scripts/mahjong.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -159,7 +159,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/],
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -1761,6 +1761,11 @@ declare namespace entities {
|
|||
ReversiSurrenderRequest,
|
||||
ReversiVerifyRequest,
|
||||
ReversiVerifyResponse,
|
||||
MahjongCreateRoomResponse,
|
||||
MahjongJoinRoomRequest,
|
||||
MahjongJoinRoomResponse,
|
||||
MahjongShowRoomRequest,
|
||||
MahjongShowRoomResponse,
|
||||
Error_2 as Error,
|
||||
UserLite,
|
||||
UserDetailedNotMeOnly,
|
||||
|
@ -1814,7 +1819,8 @@ declare namespace entities {
|
|||
MetaDetailedOnly,
|
||||
MetaDetailed,
|
||||
SystemWebhook,
|
||||
AbuseReportNotificationRecipient
|
||||
AbuseReportNotificationRecipient,
|
||||
MahjongRoomDetailed
|
||||
}
|
||||
}
|
||||
export { entities }
|
||||
|
@ -2316,6 +2322,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'];
|
||||
|
||||
|
|
|
@ -4221,5 +4221,38 @@ declare module '../api.js' {
|
|||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *write:account*
|
||||
*/
|
||||
request<E extends 'mahjong/create-room', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *write:account*
|
||||
*/
|
||||
request<E extends 'mahjong/join-room', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:account*
|
||||
*/
|
||||
request<E extends 'mahjong/show-room', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -571,6 +571,11 @@ import type {
|
|||
ReversiSurrenderRequest,
|
||||
ReversiVerifyRequest,
|
||||
ReversiVerifyResponse,
|
||||
MahjongCreateRoomResponse,
|
||||
MahjongJoinRoomRequest,
|
||||
MahjongJoinRoomResponse,
|
||||
MahjongShowRoomRequest,
|
||||
MahjongShowRoomResponse,
|
||||
} from './entities.js';
|
||||
|
||||
export type Endpoints = {
|
||||
|
@ -953,6 +958,9 @@ 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 };
|
||||
}
|
||||
|
||||
export const endpointReqTypes: Record<keyof Endpoints, 'application/json' | 'multipart/form-data'> = {
|
||||
|
@ -1335,4 +1343,7 @@ export const endpointReqTypes: Record<keyof Endpoints, 'application/json' | 'mul
|
|||
'reversi/show-game': 'application/json',
|
||||
'reversi/surrender': 'application/json',
|
||||
'reversi/verify': 'application/json',
|
||||
'mahjong/create-room': 'application/json',
|
||||
'mahjong/join-room': 'application/json',
|
||||
'mahjong/show-room': 'application/json',
|
||||
};
|
||||
|
|
|
@ -574,3 +574,8 @@ export type ReversiShowGameResponse = operations['reversi___show-game']['respons
|
|||
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'];
|
||||
|
|
|
@ -53,3 +53,4 @@ export type MetaDetailedOnly = components['schemas']['MetaDetailedOnly'];
|
|||
export type MetaDetailed = components['schemas']['MetaDetailed'];
|
||||
export type SystemWebhook = components['schemas']['SystemWebhook'];
|
||||
export type AbuseReportNotificationRecipient = components['schemas']['AbuseReportNotificationRecipient'];
|
||||
export type MahjongRoomDetailed = components['schemas']['MahjongRoomDetailed'];
|
||||
|
|