Compare commits

...

67 commits

Author SHA1 Message Date
Take-John
bf818a6656
feature(mahjong): 搶槓/ドラ以外の麻雀の役を実装 (#14346)
* ビルドによる自動的なソース更新

* 麻雀関連のキーバリューペアを追加

* 役の定義をまとめてエクスポート

* タイポ修正

* Revert "麻雀関連のキーバリューペアを追加"

This reverts commit c349cdf70c69af89d93ed7db035efaaacf2c2785.

* misskey-jsのビルドによる自動更新

* 型エラーに対処

* riichiがtrueの場合に門前であるかを確認

* EnvForCalcYakuのhouseプロパティを廃止

* 風牌の役の共通部分をクラスで定義

* タイポ修正

* 役牌をクラスで共通化

* 一盃口と二盃口のテストを通す

* 一盃口・二盃口判定関数の調整

* 一気通貫の判定にチーによる順子も考慮する

* 混全帯幺九の実装

* 純全帯幺九の実装

* 七対子の実装とテストの修正

* tsumoTileまたはronTileを必須に

* 待ちを確認して平和の判定を可能に

* 三暗刻と四暗刻、四暗刻単騎の実装

* 四暗刻であるために通常の役を判定できない牌姿のテストを修正

* 混老頭と清老頭を実装

* 三槓子と四槓子を実装

* 平和の実装とテストを修正

* 小三元のテストを修正

* 国士無双に対子の確認を追加

* 国士無双十三面待ちを実装し、テストを修正

* 一部の役の七対子形を認め、テストを追加

* 手牌の数を確認

* 役の定義をカプセル化して型エラーの対処

* ツモ・ロンの判定を修正

* calcYakusの引数のhandTilesを修正

* calcYakusに渡す風をseatWindに修正

* 嶺上開花の実装

* 海底摸月の実装

* FourMentsuOneJyantouWithWait型の作成

* 河底撈魚の実装

* ダブル立直の実装

* 天和・地和の実装

* エンジンのテストを作成

* エンジンによる地和のテストを追加

* 嶺上開花のテスト

* ライセンスの記述を追加

* ダブル立直一発ツモのテスト

* ダブル立直海底ツモのテスト

* ダブル立直河底のテスト

* 役満も処理できるように

* 点数のテスト

* 打牌時にrinshanFlags[house]をfalseに

* 七対子形の字一色を認める

* typo
2024-08-15 12:29:31 +09:00
syuilo
f32b11ba12 Merge branch 'develop' into mahjong 2024-07-28 15:18:08 +09:00
Take-John
6c9f6e8057
fix(mahjong): 麻雀をプレイできない問題を修正 (#14268)
* ビルドによる自動的なソース更新

* 麻雀関連のキーバリューペアを追加

* 役の定義をまとめてエクスポート

* タイポ修正

* Revert "麻雀関連のキーバリューペアを追加"

This reverts commit c349cdf70c69af89d93ed7db035efaaacf2c2785.

* misskey-jsのビルドによる自動更新
2024-07-25 17:09:07 +09:00
syuilo
6b16b85203 Merge branch 'develop' into mahjong 2024-07-08 13:08:57 +09:00
syuilo
4597d5db91 wip 2024-07-02 10:10:18 +09:00
syuilo
9c79f5d135 Merge branch 'develop' into mahjong 2024-07-02 10:10:01 +09:00
im_tan
0e27fa59d4
test: [Mi麻雀] 一部を除く各種役のテスト (#13397)
* fix typo

* add letter-tiles tests

* add ippatsu test

* add tanyao and pinfu test

* fix ippatsu test

* add wind tests

* add iipeko and chitoitsu test

* add sanshoku-doujunn sanshoku-doo ittsu junchan tests

* add toitoi sananko test

* add ryanpeko test

* add honroto sankantsu honitsu chintisu tests

* add shosangen test
2024-02-19 16:51:56 +09:00
syuilo
4ae591a2c7 Merge branch 'develop' into mahjong 2024-02-14 21:03:58 +09:00
im_tan
af9ebf7034
test: [Mi麻雀] 役満(天和、地和を除く)のテスト (#13263)
* add daisangen test

* add suanko test

* add suanko-tanki test

* add tsuiso test

* fix typo

* add test-shosushi

* add test-daisushi

* add ryuiso-test

* add chinroto-test

* add sukantsu-test

* add kokushi-13-test
2024-02-12 18:38:55 +09:00
syuilo
b04a0c99a4 wip 2024-02-12 11:50:46 +09:00
syuilo
10a2c16a6d Merge branch 'develop' into mahjong 2024-02-12 11:50:03 +09:00
im_tan
622fc44645
add kokushi test (#13262) 2024-02-12 09:06:00 +09:00
syuilo
3f810a856c wip 2024-02-11 14:23:37 +09:00
syuilo
c47203b888 wip 2024-02-11 12:45:50 +09:00
syuilo
c99d55e0cb Merge branch 'develop' into mahjong 2024-02-09 19:34:18 +09:00
syuilo
bb042b46ac wip 2024-02-09 15:42:33 +09:00
syuilo
084e9449dc Merge branch 'develop' into mahjong 2024-02-09 10:20:01 +09:00
syuilo
2af3710757 Merge branch 'develop' into mahjong 2024-02-08 16:59:43 +09:00
syuilo
894f65f754 wip 2024-02-07 20:52:11 +09:00
syuilo
166aeb631e wip 2024-02-06 18:21:58 +09:00
syuilo
2d6f9b083f wip 2024-02-06 15:41:57 +09:00
syuilo
7a9434414d Merge branch 'develop' into mahjong 2024-02-05 21:08:21 +09:00
syuilo
b302796e70 wip 2024-02-05 21:07:44 +09:00
syuilo
76cdb48a3e Merge branch 'develop' into mahjong 2024-02-05 15:02:55 +09:00
syuilo
b32022c20c wip 2024-02-05 15:02:43 +09:00
syuilo
b785793e41 Merge branch 'develop' into mahjong 2024-02-05 11:04:04 +09:00
syuilo
bfb6e2f461 wip 2024-02-05 10:58:56 +09:00
syuilo
38e3d248fb wip 2024-02-04 17:11:42 +09:00
syuilo
be3b2558d1 wip 2024-02-04 13:26:40 +09:00
syuilo
d57f20dc84 wip 2024-02-03 22:14:44 +09:00
syuilo
00bf57d243 wip 2024-02-03 18:02:00 +09:00
syuilo
586a458c7a wip 2024-02-03 17:00:45 +09:00
syuilo
2dd886e285 wip 2024-02-03 13:52:13 +09:00
syuilo
7cdaa10d46 wip 2024-02-02 20:56:39 +09:00
syuilo
9ea29fe84c Merge branch 'develop' into mahjong 2024-02-02 20:41:13 +09:00
syuilo
054a48c184 Update logo.png 2024-02-01 21:01:53 +09:00
syuilo
c964c49c58 wip 2024-02-01 20:34:05 +09:00
syuilo
10a112489d Merge branch 'develop' into mahjong 2024-02-01 16:42:32 +09:00
syuilo
859cf75ad3 wip 2024-01-31 18:49:42 +09:00
syuilo
3c97164cf2 wip 2024-01-31 18:31:02 +09:00
syuilo
8121f8f40f wip 2024-01-31 10:54:38 +09:00
syuilo
072928b147 Merge branch 'develop' into mahjong 2024-01-31 10:54:34 +09:00
syuilo
5af8b5d547 wip 2024-01-30 20:13:38 +09:00
syuilo
5e3a805671 Update index.d.ts 2024-01-30 19:56:16 +09:00
syuilo
ef14a56a5c Merge branch 'develop' into mahjong 2024-01-30 19:56:08 +09:00
syuilo
2f0924c85b wip 2024-01-30 17:11:16 +09:00
syuilo
4183fec4ab wip 2024-01-30 11:34:57 +09:00
syuilo
ce65e9dd69 refactor 2024-01-30 11:30:58 +09:00
syuilo
d7337e5f81 wip 2024-01-30 11:27:08 +09:00
syuilo
547b74c9b2 wip 2024-01-29 20:35:25 +09:00
syuilo
d427d24ca4 wip 2024-01-29 17:15:09 +09:00
syuilo
668bf9a226 wip 2024-01-29 14:14:00 +09:00
syuilo
11404e545e Update index.ts 2024-01-29 12:30:09 +09:00
syuilo
5f48109230 wip 2024-01-29 11:47:01 +09:00
syuilo
dad8430040 wip 2024-01-29 10:46:23 +09:00
syuilo
0111b8736a Merge branch 'develop' into mahjong 2024-01-29 07:50:16 +09:00
syuilo
1ea098f4b4 wip 2024-01-28 20:34:45 +09:00
syuilo
366fade8d3 wip 2024-01-28 20:20:18 +09:00
syuilo
db7bd0e94e wip 2024-01-28 17:31:32 +09:00
syuilo
606c88aa6b Merge branch 'develop' into mahjong 2024-01-28 15:19:24 +09:00
syuilo
55629f2b39 wip 2024-01-28 13:49:56 +09:00
syuilo
ab404d491d wip 2024-01-27 17:50:41 +09:00
syuilo
0f2991cbaf Merge branch 'develop' into mahjong 2024-01-27 12:03:08 +09:00
syuilo
34ed9cb187 Merge branch 'develop' into mahjong 2024-01-27 12:02:55 +09:00
syuilo
67e6184a75 wip 2024-01-26 14:25:00 +09:00
syuilo
2133d0552c Merge branch 'develop' into mahjong 2024-01-25 10:42:24 +09:00
syuilo
314c31db34 wip 2024-01-17 20:14:35 +09:00
122 changed files with 10029 additions and 15 deletions

View file

@ -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
View file

@ -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": {
/**
* -

View file

@ -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: "サーバーに接続できません"

View file

@ -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": {

View 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"`);
}
}

View file

@ -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",

View file

@ -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,

View file

@ -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);
}
}

View 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();
}
}

View file

@ -82,5 +82,6 @@ export const DI = {
userMemosRepository: Symbol('userMemosRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
mahjongGamesRepository: Symbol('mahjongGamesRepository'),
//#endregion
};

View file

@ -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]>;

View 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[][];
}

View file

@ -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 {

View file

@ -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>;

View 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;

View file

@ -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,
];

View file

@ -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,

View file

@ -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 {}

View file

@ -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 {

View file

@ -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);
}
});
}
}

View file

@ -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);
});
}
}

View 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);
});
}
}

View file

@ -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);
});
}
}

View file

@ -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);
});
}
}

View 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,
};
}
});
}
}

View file

@ -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}`);

View 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,
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View file

@ -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",

View file

@ -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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load diff

View 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>

View 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>

View 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>

View file

@ -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')),

View 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);
}
}

View file

@ -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/],
},
},

View file

@ -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'];

View file

@ -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>>;
}
}

View file

@ -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',
};

View file

@ -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'];

View file

@ -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'];

Some files were not shown because too many files have changed in this diff Show more