Compare commits

..

10 Commits

Author SHA1 Message Date
かっこかり 65e51463c8 fix(frontend): CSSの指定が誤っている問題を修正 (#17135) 2026-01-31 22:38:16 +09:00
Ken_Cir 39362f78a6 fix(backend): inconsistent permissions for /admin/get-user-ips (#17136)
* fix(backend): inconsistent permissions for /admin/get-user-ips

* Update Changelog
2026-01-31 22:37:48 +09:00
renovate[bot] d2154214ba fix(deps): update dependency tar to v7.5.7 [security] (#17104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 18:18:34 +09:00
renovate[bot] 97312b97f8 chore(deps): update pnpm to v10.28.2 [security] (#17133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 10:53:08 +09:00
poppingmoon 5e2b041f84 fix(frontend): remove ensureSignin from reversi game page (#17130) 2026-01-28 20:44:58 +09:00
syuilo ec97f49919 Create copilot-instructions.md 2026-01-28 19:42:43 +09:00
かっこかり 4910fff7fb fix(frontend): mCaptchaが動作しない問題を修正 (#17127)
* fix(frontend): mCaptchaが動作しない問題を修正

* Update Changelog

* remove mcaptcha vanila glue
2026-01-28 11:55:35 +09:00
かっこかり fc7655c808 deps: update dependencies [ci skip] (#17126)
* deps: update dependencies

* update vunerable packages as well
2026-01-24 02:41:10 +09:00
syuilo ae2ac9d50f fix(frontend): 投稿フォームのアップロードファイルを右クリックしたときの挙動がおかしいのを修正 2026-01-22 20:31:42 +09:00
syuilo 8932492fd3 enhance(frontend): 添付画像のメニューを右クリックでも呼び出せるように 2026-01-22 20:29:11 +09:00
147 changed files with 2288 additions and 13434 deletions
+3
View File
@@ -0,0 +1,3 @@
# Copilot Instructions for Misskey
- en-US.yml を編集しないでください。
-3
View File
@@ -15,7 +15,6 @@ on:
- packages/sw/**
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-mahjong/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- .github/workflows/lint.yml
@@ -30,7 +29,6 @@ on:
- packages/sw/**
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-mahjong/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- .github/workflows/lint.yml
@@ -66,7 +64,6 @@ jobs:
- sw
- misskey-js
- misskey-bubble-game
- misskey-mahjong
- misskey-reversi
env:
eslint-cache-version: v1
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml
- name: Build dependent packages
run: pnpm -F misskey-js -F misskey-bubble-game -F misskey-mahjong -F misskey-reversi build
run: pnpm -F misskey-js -F misskey-bubble-game -F misskey-reversi build
- name: Build storybook
run: pnpm --filter frontend build-storybook
- name: Publish to Chromatic
+3
View File
@@ -23,12 +23,15 @@
- Fix: ファイルタブのセンシティブメディアを開く際に確認ダイアログを出す設定が適用されない問題を修正
- Fix: 2月29日を誕生日に設定している場合、閏年以外は3月1日を誕生日として扱うように修正
- Fix: `Mk:C:container``borderWidth` が正しく反映されない問題を修正
- Fix: mCaptchaが正しく動作しない問題を修正
- Fix: 非ログイン時にリバーシの対局が表示されない問題を修正
### Server
- Enhance: OAuthのクライアント情報取得(Client Information Discovery)において、IndieWeb Living Standard 11 July 2024で定義されているJSONドキュメント形式に対応しました
- JSONによるClient Information Discoveryを行うには、レスポンスの`Content-Type`ヘッダーが`application/json`である必要があります
- 従来の実装(12 February 2022版・HTML Microformat形式)も引き続きサポートされます
- Enhance: メモリ使用量を削減
- Fix: `/admin/get-user-ips` エンドポイントのアクセス権限を管理者のみに修正
## 2025.12.2
-4
View File
@@ -30,7 +30,6 @@ 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
@@ -62,7 +61,6 @@ 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
@@ -99,12 +97,10 @@ 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/packages/backend/src-js ./packages/backend/src-js
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
-73
View File
@@ -3180,79 +3180,6 @@ _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: "サーバーに接続できません"
+12 -15
View File
@@ -6,13 +6,12 @@
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@10.27.0",
"packageManager": "pnpm@10.28.2",
"workspaces": [
"packages/misskey-js",
"packages/i18n",
"packages/misskey-reversi",
"packages/misskey-bubble-game",
"packages/misskey-mahjong",
"packages/icons-subsetter",
"packages/frontend-shared",
"packages/frontend-builder",
@@ -53,10 +52,6 @@
"clean-all": "node scripts/clean-all.mjs",
"cleanall": "pnpm clean-all"
},
"resolutions": {
"chokidar": "5.0.0",
"lodash": "4.17.21"
},
"dependencies": {
"cssnano": "7.1.2",
"esbuild": "0.27.2",
@@ -64,23 +59,23 @@
"ignore-walk": "8.0.0",
"js-yaml": "4.1.1",
"postcss": "8.5.6",
"tar": "7.5.2",
"terser": "5.44.1"
"tar": "7.5.7",
"terser": "5.46.0"
},
"devDependencies": {
"@eslint/js": "9.39.2",
"@misskey-dev/eslint-plugin": "2.2.0",
"@types/js-yaml": "4.0.9",
"@types/node": "24.10.4",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@typescript/native-preview": "7.0.0-dev.20251226.1",
"@types/node": "24.10.9",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@typescript/native-preview": "7.0.0-dev.20260116.1",
"cross-env": "10.1.0",
"cypress": "15.8.1",
"cypress": "15.9.0",
"eslint": "9.39.2",
"globals": "16.5.0",
"ncp": "2.0.0",
"pnpm": "10.27.0",
"pnpm": "10.28.2",
"typescript": "5.9.3",
"start-server-and-test": "2.1.3"
},
@@ -89,7 +84,9 @@
},
"pnpm": {
"overrides": {
"@aiscript-dev/aiscript-languageserver": "-"
"@aiscript-dev/aiscript-languageserver": "-",
"chokidar": "5.0.0",
"lodash": "4.17.23"
},
"ignoredBuiltDependencies": [
"@sentry-internal/node-cpu-profiler",
@@ -1,24 +0,0 @@
/*
* 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"`);
}
}
+45 -46
View File
@@ -41,17 +41,17 @@
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.15.7",
"@swc/core-darwin-x64": "1.15.7",
"@swc/core-darwin-arm64": "1.15.8",
"@swc/core-darwin-x64": "1.15.8",
"@swc/core-freebsd-x64": "1.3.11",
"@swc/core-linux-arm-gnueabihf": "1.15.7",
"@swc/core-linux-arm64-gnu": "1.15.7",
"@swc/core-linux-arm64-musl": "1.15.7",
"@swc/core-linux-x64-gnu": "1.15.7",
"@swc/core-linux-x64-musl": "1.15.7",
"@swc/core-win32-arm64-msvc": "1.15.7",
"@swc/core-win32-ia32-msvc": "1.15.7",
"@swc/core-win32-x64-msvc": "1.15.7",
"@swc/core-linux-arm-gnueabihf": "1.15.8",
"@swc/core-linux-arm64-gnu": "1.15.8",
"@swc/core-linux-arm64-musl": "1.15.8",
"@swc/core-linux-x64-gnu": "1.15.8",
"@swc/core-linux-x64-musl": "1.15.8",
"@swc/core-win32-arm64-msvc": "1.15.8",
"@swc/core-win32-ia32-msvc": "1.15.8",
"@swc/core-win32-x64-msvc": "1.15.8",
"@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.1.0",
@@ -71,30 +71,30 @@
"utf-8-validate": "6.0.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.958.0",
"@aws-sdk/lib-storage": "3.958.0",
"@aws-sdk/client-s3": "3.970.0",
"@aws-sdk/lib-storage": "3.970.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/express": "4.0.2",
"@fastify/express": "4.0.4",
"@fastify/http-proxy": "11.4.1",
"@fastify/multipart": "9.3.0",
"@fastify/static": "8.3.0",
"@kitajs/html": "4.2.11",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.5",
"@napi-rs/canvas": "0.1.87",
"@nestjs/common": "11.1.10",
"@nestjs/core": "11.1.10",
"@nestjs/testing": "11.1.10",
"@napi-rs/canvas": "0.1.88",
"@nestjs/common": "11.1.12",
"@nestjs/core": "11.1.12",
"@nestjs/testing": "11.1.12",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.32.1",
"@sentry/profiling-node": "10.32.1",
"@sentry/node": "10.34.0",
"@sentry/profiling-node": "10.34.0",
"@simplewebauthn/server": "13.2.2",
"@sinonjs/fake-timers": "15.1.0",
"@smithy/node-http-handler": "4.4.7",
"@swc/cli": "0.7.9",
"@swc/core": "1.15.7",
"@smithy/node-http-handler": "4.4.8",
"@swc/cli": "0.7.10",
"@swc/core": "1.15.8",
"@twemoji/parser": "16.0.0",
"accepts": "1.3.8",
"ajv": "8.17.1",
@@ -102,8 +102,8 @@
"async-mutex": "0.5.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"body-parser": "2.2.1",
"bullmq": "5.66.3",
"body-parser": "2.2.2",
"bullmq": "5.66.5",
"cacheable-lookup": "7.0.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
@@ -112,41 +112,40 @@
"content-disposition": "1.0.1",
"date-fns": "4.1.0",
"deep-email-validator": "0.1.21",
"fastify": "5.6.2",
"fastify": "5.7.1",
"fastify-raw-body": "5.0.0",
"feed": "5.1.0",
"file-type": "21.2.0",
"feed": "5.2.0",
"file-type": "21.3.0",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.5",
"got": "14.6.5",
"got": "14.6.6",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
"ioredis": "5.8.2",
"ioredis": "5.9.2",
"ip-cidr": "4.0.2",
"ipaddr.js": "2.3.0",
"is-svg": "6.1.0",
"json5": "2.2.3",
"jsonld": "9.0.0",
"juice": "11.0.3",
"meilisearch": "0.54.0",
"juice": "11.1.0",
"meilisearch": "0.55.0",
"mfm-js": "0.25.0",
"mime-types": "3.0.2",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"misskey-mahjong": "workspace:*",
"ms": "3.0.0-canary.202508261828",
"nanoid": "5.1.6",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.0.1",
"node-html-parser": "7.0.2",
"nodemailer": "7.0.12",
"nsfwjs": "4.2.0",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.4.1",
"pg": "8.16.3",
"pg": "8.17.1",
"pkce-challenge": "5.0.1",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
@@ -165,7 +164,7 @@
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.28.1",
"systeminformation": "5.30.5",
"tinycolor2": "1.6.0",
"tmp": "0.2.5",
"tsc-alias": "1.8.16",
@@ -173,14 +172,14 @@
"ulid": "3.0.2",
"vary": "1.1.2",
"web-push": "3.6.7",
"ws": "8.18.3",
"ws": "8.19.0",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.7.0",
"@kitajs/ts-html-plugin": "4.1.3",
"@nestjs/platform-express": "11.1.10",
"@sentry/vue": "10.32.1",
"@nestjs/platform-express": "11.1.12",
"@sentry/vue": "10.34.0",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.39",
"@types/accepts": "1.3.7",
@@ -194,8 +193,8 @@
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "24.10.4",
"@types/nodemailer": "7.0.4",
"@types/node": "24.10.9",
"@types/nodemailer": "7.0.5",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.16.0",
@@ -213,22 +212,22 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.11",
"cross-env": "10.1.0",
"esbuild-plugin-swc": "1.0.1",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.1",
"fkill": "10.0.3",
"jest": "29.7.0",
"jest-mock": "29.7.0",
"js-yaml": "4.1.1",
"nodemon": "3.1.11",
"pid-port": "2.0.0",
"pid-port": "2.0.1",
"simple-oauth2": "5.1.0",
"supertest": "7.1.4",
"vite": "7.3.0"
"supertest": "7.2.2",
"vite": "7.3.1"
}
}
-6
View File
@@ -78,7 +78,6 @@ import { ChannelFollowingService } from './ChannelFollowingService.js';
import { ChatService } from './ChatService.js';
import { RegistryApiService } from './RegistryApiService.js';
import { ReversiService } from './ReversiService.js';
import { MahjongService } from './MahjongService.js';
import { PageService } from './PageService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
@@ -229,7 +228,6 @@ const $ChannelMutingService: Provider = { provide: 'ChannelMutingService', useEx
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $MahjongService: Provider = { provide: 'MahjongService', useExisting: MahjongService };
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
@@ -383,7 +381,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChatService,
RegistryApiService,
ReversiService,
MahjongService,
PageService,
ChartLoggerService,
@@ -534,7 +531,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChatService,
$RegistryApiService,
$ReversiService,
$MahjongService,
$PageService,
$ChartLoggerService,
@@ -685,7 +681,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChatService,
RegistryApiService,
ReversiService,
MahjongService,
PageService,
FederationChart,
@@ -834,7 +829,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChatService,
$RegistryApiService,
$ReversiService,
$MahjongService,
$PageService,
$FederationChart,
@@ -6,7 +6,6 @@
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';
@@ -202,78 +201,6 @@ export interface ReversiGameEventTypes {
userId: MiUser['id'];
};
}
export interface MahjongRoomEventTypes {
joined: {
index: number;
user: Packed<'UserLite'> | null;
};
changeReadyStates: {
user1: boolean;
user2: boolean;
user3: boolean;
user4: boolean;
};
started: {
room: Packed<'MahjongRoomDetailed'>;
};
nextKyoku: {
room: Packed<'MahjongRoomDetailed'>;
};
tsumo: {
house: Mmj.House;
tile: number;
};
dahai: {
house: Mmj.House;
tile: number;
riichi: boolean;
};
dahaiAndTsumo: {
dahaiHouse: Mmj.House;
dahaiTile: number;
tsumoTile: number;
riichi: boolean;
};
ponned: {
caller: Mmj.House;
callee: Mmj.House;
tiles: readonly [number, number, number];
};
kanned: {
caller: Mmj.House;
callee: Mmj.House;
tiles: readonly [number, number, number, number];
rinsyan: number;
};
ciied: {
caller: Mmj.House;
callee: Mmj.House;
tiles: readonly [number, number, number];
};
ronned: {
callers: Mmj.House[];
callee: Mmj.House;
handTiles: Record<Mmj.House, number[]>;
};
ryuukyoku: object;
ankanned: {
house: Mmj.House;
tiles: readonly [number, number, number, number];
rinsyan: number;
};
kakanned: {
house: Mmj.House;
tiles: readonly [number, number, number, number];
rinsyan: number;
from: Mmj.House;
};
tsumoHora: {
house: Mmj.House;
handTiles: number[];
tsumoTile: number;
};
}
//#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義
@@ -393,10 +320,6 @@ export type GlobalEvents = {
name: `reversiGameStream:${MiReversiGame['id']}`;
payload: EventTypesToEventPayload<ReversiGameEventTypes>;
};
mahjongRoom: {
name: `mahjongRoomStream:${string}`;
payload: EventUnionFromDictionary<SerializedAll<MahjongRoomEventTypes>>;
};
};
// API event definitions
@@ -506,9 +429,4 @@ export class GlobalEventService {
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishMahjongRoomStream<K extends keyof MahjongRoomEventTypes>(roomId: string, type: K, value?: MahjongRoomEventTypes[K]): void {
this.publish(`mahjongRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value);
}
}
-734
View File
@@ -1,734 +0,0 @@
/*
* 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();
}
}
-1
View File
@@ -90,7 +90,6 @@ export const DI = {
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
mahjongGamesRepository: Symbol('mahjongGamesRepository'),
noteDraftsRepository: Symbol('noteDraftsRepository'),
//#endregion
};
-2
View File
@@ -73,7 +73,6 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage
import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
import { packedMahjongRoomDetailedSchema } from '@/models/json-schema/mahjong-room.js';
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js';
@@ -148,7 +147,6 @@ export const refs = {
ChatRoom: packedChatRoomSchema,
ChatRoomInvitation: packedChatRoomInvitationSchema,
ChatRoomMembership: packedChatRoomMembershipSchema,
MahjongRoomDetailed: packedMahjongRoomDetailedSchema,
};
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
@@ -1,94 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('mahjong_game')
export class MiMahjongGame {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
nullable: true,
})
public startedAt: Date | null;
@Column('timestamp with time zone', {
nullable: true,
})
public endedAt: Date | null;
@Column({
...id(),
nullable: true,
})
public user1Id: MiUser['id'] | null;
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user1: MiUser | null;
@Column({
...id(),
nullable: true,
})
public user2Id: MiUser['id'] | null;
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user2: MiUser | null;
@Column({
...id(),
nullable: true,
})
public user3Id: MiUser['id'] | null;
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user3: MiUser | null;
@Column({
...id(),
nullable: true,
})
public user4Id: MiUser['id'] | null;
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user4: MiUser | null;
@Column('boolean', {
default: false,
})
public isEnded: boolean;
@Column({
...id(),
nullable: true,
})
public winnerId: MiUser['id'] | null;
// in sec
@Column('smallint', {
default: 90,
})
public timeLimitForEachTurn: number;
@Column('jsonb', {
default: [],
})
public logs: number[][];
}
@@ -84,7 +84,6 @@ import {
MiChatRoomMembership,
MiChatRoomInvitation,
MiChatApproval,
MiMahjongGame,
} from './_.js';
import type { Provider } from '@nestjs/common';
import type { DataSource } from 'typeorm';
@@ -545,12 +544,6 @@ const $reversiGamesRepository: Provider = {
inject: [DI.db],
};
const $mahjongGamesRepository: Provider = {
provide: DI.mahjongGamesRepository,
useFactory: (db: DataSource) => db.getRepository(MiMahjongGame),
inject: [DI.db],
};
@Module({
imports: [],
providers: [
@@ -630,7 +623,6 @@ const $mahjongGamesRepository: Provider = {
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
$mahjongGamesRepository,
],
exports: [
$usersRepository,
@@ -709,7 +701,6 @@ const $mahjongGamesRepository: Provider = {
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
$mahjongGamesRepository,
],
})
export class RepositoryModule {
-3
View File
@@ -43,7 +43,6 @@ import { MiGalleryLike } from '@/models/GalleryLike.js';
import { MiGalleryPost } from '@/models/GalleryPost.js';
import { MiHashtag } from '@/models/Hashtag.js';
import { MiInstance } from '@/models/Instance.js';
import { MiMahjongGame } from '@/models/MahjongGame.js';
import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMuting } from '@/models/Muting.js';
@@ -174,7 +173,6 @@ export {
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
MiMahjongGame,
};
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
@@ -255,4 +253,3 @@ export type ChatRoomInvitationsRepository = Repository<MiChatRoomInvitation> & M
export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
export type MahjongGamesRepository = Repository<MiMahjongGame> & MiRepository<MiMahjongGame>;
@@ -1,114 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedMahjongRoomDetailedSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
startedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
endedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
isStarted: {
type: 'boolean',
optional: false, nullable: false,
},
isEnded: {
type: 'boolean',
optional: false, nullable: false,
},
user1Id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user2Id: {
type: 'string',
optional: false, nullable: true,
format: 'id',
},
user3Id: {
type: 'string',
optional: false, nullable: true,
format: 'id',
},
user4Id: {
type: 'string',
optional: false, nullable: true,
format: 'id',
},
user1: {
type: 'object',
optional: false, nullable: true,
ref: 'User',
},
user2: {
type: 'object',
optional: false, nullable: true,
ref: 'User',
},
user3: {
type: 'object',
optional: false, nullable: true,
ref: 'User',
},
user4: {
type: 'object',
optional: false, nullable: true,
ref: 'User',
},
user1Ai: {
type: 'boolean',
optional: false, nullable: false,
},
user2Ai: {
type: 'boolean',
optional: false, nullable: false,
},
user3Ai: {
type: 'boolean',
optional: false, nullable: false,
},
user4Ai: {
type: 'boolean',
optional: false, nullable: false,
},
user1Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user2Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user3Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user4Ready: {
type: 'boolean',
optional: false, nullable: false,
},
timeLimitForEachTurn: {
type: 'number',
optional: false, nullable: false,
},
},
} as const;
-2
View File
@@ -85,7 +85,6 @@ import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiMahjongGame } from '@/models/MahjongGame.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiSystemAccount } from '@/models/SystemAccount.js';
@@ -255,7 +254,6 @@ export const entities = [
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
MiMahjongGame,
...charts,
];
+1 -3
View File
@@ -6,7 +6,6 @@
import { Module } from '@nestjs/common';
import { EndpointsModule } from '@/server/api/EndpointsModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { ApiCallService } from './api/ApiCallService.js';
import { FileServerService } from './FileServerService.js';
import { HealthServerService } from './HealthServerService.js';
@@ -31,6 +30,7 @@ import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { MainChannel } from './api/stream/channels/main.js';
import { AdminChannel } from './api/stream/channels/admin.js';
import { AntennaChannel } from './api/stream/channels/antenna.js';
@@ -49,7 +49,6 @@ import { ChatUserChannel } from './api/stream/channels/chat-user.js';
import { ChatRoomChannel } from './api/stream/channels/chat-room.js';
import { ReversiChannel } from './api/stream/channels/reversi.js';
import { ReversiGameChannel } from './api/stream/channels/reversi-game.js';
import { MahjongRoomChannel } from './api/stream/channels/mahjong-room.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@Module({
@@ -93,7 +92,6 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
ChatRoomChannel,
ReversiChannel,
ReversiGameChannel,
MahjongRoomChannel,
HomeTimelineChannel,
HybridTimelineChannel,
LocalTimelineChannel,
@@ -301,12 +301,6 @@ export * as 'invite/create' from './endpoints/invite/create.js';
export * as 'invite/delete' from './endpoints/invite/delete.js';
export * as 'invite/limit' from './endpoints/invite/limit.js';
export * as 'invite/list' from './endpoints/invite/list.js';
export * as 'mahjong/cancel-match' from './endpoints/mahjong/cancel-match.js';
export * as 'mahjong/create-room' from './endpoints/mahjong/create-room.js';
export * as 'mahjong/games' from './endpoints/mahjong/games.js';
export * as 'mahjong/join-room' from './endpoints/mahjong/join-room.js';
export * as 'mahjong/show-room' from './endpoints/mahjong/show-room.js';
export * as 'mahjong/verify' from './endpoints/mahjong/verify.js';
export * as 'meta' from './endpoints/meta.js';
export * as 'miauth/gen-token' from './endpoints/miauth/gen-token.js';
export * as 'mute/create' from './endpoints/mute/create.js';
@@ -13,7 +13,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'read:admin:user-ips',
res: {
type: 'array',
@@ -1,41 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ReversiService } from '@/core/ReversiService.js';
export const meta = {
requireCredential: true,
kind: 'write:account',
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<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);
}
});
}
}
@@ -1,44 +0,0 @@
/*
* 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);
});
}
}
@@ -1,64 +0,0 @@
/*
* 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);
});
}
}
@@ -1,56 +0,0 @@
/*
* 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);
});
}
}
@@ -1,54 +0,0 @@
/*
* 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);
});
}
}
@@ -1,64 +0,0 @@
/*
* 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,
};
}
});
}
}
@@ -4,7 +4,6 @@
*/
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { HybridTimelineChannel } from './channels/hybrid-timeline.js';
import { LocalTimelineChannel } from './channels/local-timeline.js';
import { HomeTimelineChannel } from './channels/home-timeline.js';
@@ -23,8 +22,8 @@ import { ChatUserChannel } from './channels/chat-user.js';
import { ChatRoomChannel } from './channels/chat-room.js';
import { ReversiChannel } from './channels/reversi.js';
import { ReversiGameChannel } from './channels/reversi-game.js';
import { MahjongRoomChannel } from './channels/mahjong-room.js';
import type { ChannelConstructor } from './channel.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class ChannelsService {
@@ -53,7 +52,6 @@ export class ChannelsService {
case 'chatRoom': return ChatRoomChannel;
case 'reversi': return ReversiChannel;
case 'reversiGame': return ReversiGameChannel;
case 'mahjongRoom': return MahjongRoomChannel;
default:
throw new Error(`no such channel: ${name}`);
@@ -1,185 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { bindThis } from '@/decorators.js';
import { MahjongService } from '@/core/MahjongService.js';
import { GlobalEvents } from '@/core/GlobalEventService.js';
import Channel, { type ChannelRequest } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export 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(
@Inject(REQUEST)
request: ChannelRequest,
private mahjongService: MahjongService,
) {
super(request);
}
@bindThis
public async init(params: any) {
this.roomId = params.roomId as string;
this.subscriber.on(`mahjongRoomStream:${this.roomId}`, this.onMahjongRoomStreamMessage);
}
@bindThis
private async onMahjongRoomStreamMessage(message: GlobalEvents['mahjongRoom']['payload']) {
if (message.type === 'started') {
const packed = await this.mahjongService.packRoom(message.body.room, this.user!);
this.send('started', {
room: packed,
});
} else if (message.type === 'nextKyoku') {
const packed = this.mahjongService.packState(message.body.room, this.user!);
this.send('nextKyoku', {
state: packed,
});
} else {
this.send(message.type, message.body);
}
}
@bindThis
public onMessage(type: string, body: any) {
switch (type) {
case 'ready': this.ready(body); break;
case 'updateSettings': this.updateSettings(body.key, body.value); break;
case 'addAi': this.addAi(); break;
case 'leave': this.leaveRoom(); break;
case 'confirmNextKyoku': this.confirmNextKyoku(); break;
case 'dahai': this.dahai(body.tile, body.riichi); break;
case 'tsumoHora': this.tsumoHora(); break;
case 'ronHora': this.ronHora(); break;
case 'pon': this.pon(); break;
case 'cii': this.cii(body.pattern); break;
case 'kan': this.kan(); break;
case 'ankan': this.ankan(body.tile); break;
case 'kakan': this.kakan(body.tile); break;
case 'nop': this.nop(); break;
case 'claimTimeIsUp': this.claimTimeIsUp(); break;
}
}
@bindThis
private async updateSettings(key: string, value: any) {
if (this.user == null) return;
this.mahjongService.updateSettings(this.roomId!, this.user, key, value);
}
@bindThis
private async ready(ready: boolean) {
if (this.user == null) return;
this.mahjongService.changeReadyState(this.roomId!, this.user, ready);
}
@bindThis
private async confirmNextKyoku() {
if (this.user == null) return;
this.mahjongService.confirmNextKyoku(this.roomId!, this.user);
}
@bindThis
private async addAi() {
if (this.user == null) return;
this.mahjongService.addAi(this.roomId!, this.user);
}
@bindThis
private async leaveRoom() {
if (this.user == null) return;
this.mahjongService.leaveRoom(this.roomId!, this.user);
}
@bindThis
private async dahai(tile: number, riichi = false) {
if (this.user == null) return;
this.mahjongService.commit_dahai(this.roomId!, this.user, tile, riichi);
}
@bindThis
private async tsumoHora() {
if (this.user == null) return;
this.mahjongService.commit_tsumoHora(this.roomId!, this.user);
}
@bindThis
private async ronHora() {
if (this.user == null) return;
this.mahjongService.commit_ronHora(this.roomId!, this.user);
}
@bindThis
private async pon() {
if (this.user == null) return;
this.mahjongService.commit_pon(this.roomId!, this.user);
}
@bindThis
private async cii(pattern: string) {
if (this.user == null) return;
this.mahjongService.commit_cii(this.roomId!, this.user, pattern);
}
@bindThis
private async kan() {
if (this.user == null) return;
this.mahjongService.commit_kan(this.roomId!, this.user);
}
@bindThis
private async ankan(tile: number) {
if (this.user == null) return;
this.mahjongService.commit_ankan(this.roomId!, this.user, tile);
}
@bindThis
private async kakan(tile: number) {
if (this.user == null) return;
this.mahjongService.commit_kakan(this.roomId!, this.user, tile);
}
@bindThis
private async nop() {
if (this.user == null) return;
this.mahjongService.commit_nop(this.roomId!, this.user);
}
@bindThis
private async claimTimeIsUp() {
if (this.user == null) return;
this.mahjongService.checkTimeout(this.roomId!);
}
@bindThis
public dispose() {
// Unsubscribe events
this.subscriber.off(`mahjongRoomStream:${this.roomId}`, this.onMahjongRoomStreamMessage);
}
}
@@ -78,15 +78,6 @@ services:
source: ../../misskey-reversi/package.json
target: /misskey/packages/misskey-reversi/package.json
read_only: true
- type: bind
source: ../../misskey-mahjong/built
target: /misskey/packages/misskey-mahjong/built
read_only: true
- type: bind
source: ../../misskey-mahjong/package.json
target: /misskey/packages/misskey-mahjong/package.json
read_only: true
- type: bind
source: ../../../healthcheck.sh
target: /misskey/healthcheck.sh
+5 -5
View File
@@ -11,15 +11,15 @@
},
"devDependencies": {
"@types/estree": "1.0.8",
"@types/node": "24.10.4",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"rollup": "4.54.0"
"@types/node": "24.10.9",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"rollup": "4.55.1"
},
"dependencies": {
"i18n": "workspace:*",
"estree-walker": "3.0.3",
"magic-string": "0.30.21",
"vite": "7.3.0"
"vite": "7.3.1"
}
}
+14 -14
View File
@@ -25,12 +25,12 @@
"mfm-js": "0.25.0",
"misskey-js": "workspace:*",
"punycode.js": "2.3.1",
"rollup": "4.54.0",
"sass": "1.97.1",
"shiki": "3.20.0",
"rollup": "4.55.1",
"sass": "1.97.2",
"shiki": "3.21.0",
"tinycolor2": "1.6.0",
"uuid": "13.0.0",
"vite": "7.3.0",
"vite": "7.3.1",
"vue": "3.5.26"
},
"devDependencies": {
@@ -39,29 +39,29 @@
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.8",
"@types/micromatch": "4.0.10",
"@types/node": "24.10.4",
"@types/node": "24.10.9",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@vitest/coverage-v8": "4.0.16",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitest/coverage-v8": "4.0.17",
"@vue/runtime-core": "3.5.26",
"acorn": "8.15.0",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.6.2",
"happy-dom": "20.0.11",
"eslint-plugin-vue": "10.7.0",
"happy-dom": "20.3.1",
"intersection-observer": "0.12.2",
"micromatch": "4.0.8",
"msw": "2.12.6",
"msw": "2.12.7",
"nodemon": "3.1.11",
"prettier": "3.7.4",
"prettier": "3.8.0",
"start-server-and-test": "2.1.3",
"tsx": "4.21.0",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "3.2.1",
"vue-component-type-helpers": "3.2.2",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.2.1"
"vue-tsc": "3.2.2"
}
}
+4 -4
View File
@@ -21,11 +21,11 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.10.4",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@types/node": "24.10.9",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"esbuild": "0.27.2",
"eslint-plugin-vue": "10.6.2",
"eslint-plugin-vue": "10.7.0",
"nodemon": "3.1.11",
"vue-eslint-parser": "10.2.0"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

+27 -28
View File
@@ -19,12 +19,12 @@
"@analytics/google-analytics": "1.1.0",
"@discordapp/twemoji": "16.0.1",
"@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-rc2",
"@mcaptcha/core-glue": "0.1.0-alpha-5",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@sentry/vue": "10.32.1",
"@sentry/vue": "10.34.0",
"@syuilo/aiscript": "1.2.1",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0",
@@ -39,13 +39,13 @@
"chartjs-chart-matrix": "3.0.0",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.2.0",
"chromatic": "13.3.4",
"chromatic": "13.3.5",
"compare-versions": "6.1.1",
"cropperjs": "2.1.0",
"date-fns": "4.1.0",
"eventemitter3": "5.0.1",
"execa": "9.6.1",
"exifreader": "4.33.1",
"exifreader": "4.36.0",
"frontend-shared": "workspace:*",
"i18n": "workspace:*",
"icons-subsetter": "workspace:*",
@@ -55,26 +55,25 @@
"is-file-animated": "1.0.2",
"json5": "2.2.3",
"matter-js": "0.20.0",
"mediabunny": "1.27.2",
"mediabunny": "1.28.0",
"mfm-js": "0.25.0",
"misskey-bubble-game": "workspace:*",
"misskey-mahjong": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",
"rollup": "4.54.0",
"rollup": "4.55.1",
"sanitize-html": "2.17.0",
"sass": "1.97.1",
"shiki": "3.20.0",
"sass": "1.97.2",
"shiki": "3.21.0",
"textarea-caret": "3.1.0",
"three": "0.182.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"v-code-diff": "1.13.1",
"vite": "7.3.0",
"vite": "7.3.1",
"vue": "3.5.26",
"wanakana": "5.3.1"
},
@@ -82,7 +81,7 @@
"@misskey-dev/summaly": "5.2.5",
"@storybook/addon-essentials": "8.6.15",
"@storybook/addon-interactions": "8.6.15",
"@storybook/addon-links": "10.1.10",
"@storybook/addon-links": "10.1.11",
"@storybook/addon-mdx-gfm": "8.6.15",
"@storybook/addon-storysource": "8.6.15",
"@storybook/blocks": "8.6.15",
@@ -90,13 +89,13 @@
"@storybook/core-events": "8.6.15",
"@storybook/manager-api": "8.6.15",
"@storybook/preview-api": "8.6.15",
"@storybook/react": "10.1.10",
"@storybook/react-vite": "10.1.10",
"@storybook/react": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@storybook/test": "8.6.15",
"@storybook/theming": "8.6.15",
"@storybook/types": "8.6.15",
"@storybook/vue3": "10.1.10",
"@storybook/vue3-vite": "10.1.10",
"@storybook/vue3": "10.1.11",
"@storybook/vue3-vite": "10.1.11",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0",
@@ -104,46 +103,46 @@
"@types/insert-text-at-cursor": "0.3.2",
"@types/matter-js": "0.20.2",
"@types/micromatch": "4.0.10",
"@types/node": "24.10.4",
"@types/node": "24.10.9",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.16.0",
"@types/seedrandom": "3.0.8",
"@types/textarea-caret": "3.0.4",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@vitest/coverage-v8": "4.0.16",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitest/coverage-v8": "4.0.17",
"@vue/compiler-core": "3.5.26",
"acorn": "8.15.0",
"astring": "1.9.0",
"cross-env": "10.1.0",
"cypress": "15.8.1",
"cypress": "15.9.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.6.2",
"eslint-plugin-vue": "10.7.0",
"estree-walker": "3.0.3",
"happy-dom": "20.0.11",
"happy-dom": "20.3.1",
"intersection-observer": "0.12.2",
"magic-string": "0.30.21",
"micromatch": "4.0.8",
"minimatch": "10.1.1",
"msw": "2.12.6",
"msw": "2.12.7",
"msw-storybook-addon": "2.0.6",
"nodemon": "3.1.11",
"prettier": "3.7.4",
"prettier": "3.8.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"seedrandom": "3.0.5",
"start-server-and-test": "2.1.3",
"storybook": "10.1.10",
"storybook": "10.1.11",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.21.0",
"vite-plugin-glsl": "1.5.5",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "4.0.16",
"vitest": "4.0.17",
"vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "3.2.1",
"vue-component-type-helpers": "3.2.2",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.2.1"
"vue-tsc": "3.2.2"
}
}
+44 -19
View File
@@ -7,8 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<span v-if="!available">Loading<MkEllipsis/></span>
<div v-if="props.provider == 'mcaptcha'">
<div id="mcaptcha__widget-container" class="m-captcha-style"></div>
<div ref="captchaEl"></div>
<iframe
v-if="mCaptchaIframeUrl != null"
ref="mCaptchaIframe"
:src="mCaptchaIframeUrl"
style="border: none; max-width: 320px; width: 100%; height: 100%; max-height: 80px;"
></iframe>
</div>
<div v-if="props.provider == 'testcaptcha'" style="background: #eee; border: solid 1px #888; padding: 8px; color: #000; max-width: 320px; display: flex; gap: 10px; align-items: center; box-shadow: 2px 2px 6px #0004; border-radius: 4px;">
<img src="/client-assets/testcaptcha.png" style="width: 60px; height: 60px; "/>
@@ -26,7 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted, nextTick } from 'vue';
import type Reciever_typeReferenceOnly from '@mcaptcha/core-glue';
import { store } from '@/store.js';
// APIs provided by Captcha services
@@ -71,6 +76,19 @@ const available = ref(false);
const captchaEl = useTemplateRef('captchaEl');
const captchaWidgetId = ref<string | undefined>(undefined);
let mCaptchaReciever: Reciever_typeReferenceOnly | null = null;
const mCaptchaIframe = useTemplateRef('mCaptchaIframe');
const mCaptchaRemoveState = ref(false);
const mCaptchaIframeUrl = computed(() => {
if (props.provider === 'mcaptcha' && !mCaptchaRemoveState.value && props.instanceUrl && props.sitekey) {
const url = new URL('/widget', props.instanceUrl);
url.searchParams.set('sitekey', props.sitekey);
return url.toString();
}
return null;
});
const testcaptchaInput = ref('');
const testcaptchaPassed = ref(false);
@@ -129,8 +147,14 @@ function reset() {
if (_DEV_) console.warn(error);
}
}
testcaptchaPassed.value = false;
testcaptchaInput.value = '';
if (mCaptchaReciever != null) {
mCaptchaReciever.destroy();
mCaptchaReciever = null;
}
}
function remove() {
@@ -143,6 +167,10 @@ function remove() {
if (_DEV_) console.warn(error);
}
}
if (props.provider === 'mcaptcha') {
mCaptchaRemoveState.value = true;
}
}
async function requestRender() {
@@ -160,32 +188,29 @@ async function requestRender() {
'error-callback': () => callback(undefined),
});
} else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) {
const { default: Widget } = await import('@mcaptcha/vanilla-glue');
new Widget({
const { default: Reciever } = await import('@mcaptcha/core-glue');
mCaptchaReciever = new Reciever({
siteKey: {
instanceUrl: new URL(props.instanceUrl),
key: props.sitekey,
instanceUrl: new URL(props.instanceUrl),
},
}, (token: string) => {
callback(token);
});
mCaptchaReciever.listen();
mCaptchaRemoveState.value = false;
} else {
window.setTimeout(requestRender, 1);
window.setTimeout(requestRender, 50);
}
}
function clearWidget() {
if (props.provider === 'mcaptcha') {
const container = window.document.getElementById('mcaptcha__widget-container');
if (container) {
container.innerHTML = '';
}
} else {
reset();
remove();
reset();
remove();
if (captchaEl.value) {
//
captchaEl.value.innerHTML = '';
}
if (captchaEl.value) {
//
captchaEl.value.innerHTML = '';
}
}
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -123,7 +123,7 @@ watch(() => props.image, (newImage) => {
immediate: true,
});
function showMenu(ev: PointerEvent) {
function getMenu() {
const menuItems: MenuItem[] = [];
menuItems.push({
@@ -188,9 +188,16 @@ function showMenu(ev: PointerEvent) {
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
return menuItems;
}
function showMenu(ev: PointerEvent) {
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getMenu(), ev);
}
</script>
<style lang="scss" module>
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
tabindex="0"
@click="showFileMenu(item, $event)"
@keydown.space.enter="showFileMenu(item, $event)"
@contextmenu.prevent="showFileMenu(item, $event)"
@contextmenu.prevent.stop="showFileMenu(item, $event)"
>
<!-- pointer-eventsをnoneにしておかないとiOSなどでドラッグしたときに画像の方に判定が持ってかれる -->
<MkDriveFileThumbnail style="pointer-events: none;" :data-id="item.id" :class="$style.thumbnail" :file="item" fit="cover"/>
-5
View File
@@ -17,11 +17,6 @@ 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>
</div>
</PageWithHeader>
@@ -1,184 +0,0 @@
<!--
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>
@@ -1,46 +0,0 @@
<!--
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>
@@ -1,166 +0,0 @@
<!--
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 { useInterval } from '@@/js/use-interval.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.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 '@/i.js';
import MkPagination from '@/components/MkPagination.vue';
import { useRouter } from '@/router.js';
import * as os from '@/os.js';
import * as sound from '@/utility/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}`);
}
definePage(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
@@ -1,165 +0,0 @@
<!--
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 type { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js';
import { deepClone } from '@/utility/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 { useRouter } from '@/router.js';
const $i = ensureSignin();
const router = useRouter();
const props = defineProps<{
room: Misskey.entities.MahjongRoomDetailed;
connection: Misskey.ChannelConnection<Misskey.Channels['mahjongRoom']>;
}>();
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>

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