Compare commits
98 Commits
5f3bf94b35
...
5fa14cd6a0
| Author | SHA1 | Date |
|---|---|---|
|
|
5fa14cd6a0 | |
|
|
ae2ac9d50f | |
|
|
8932492fd3 | |
|
|
03971bf986 | |
|
|
a168e7b648 | |
|
|
1adcb03b93 | |
|
|
b6e737dc76 | |
|
|
2fa6ecc7ef | |
|
|
f744b5711f | |
|
|
08969aaf60 | |
|
|
069e48972a | |
|
|
bd0ea57661 | |
|
|
836886cd40 | |
|
|
8a4192eaa5 | |
|
|
ebaf78cddd | |
|
|
431223fbd7 | |
|
|
b881a17c2f | |
|
|
b71e299c8f | |
|
|
dc55fba800 | |
|
|
ccb6007d32 | |
|
|
5ec6993876 | |
|
|
2b5ecf883f | |
|
|
3aebda7161 | |
|
|
0a5b433557 | |
|
|
dd8bd23d64 | |
|
|
977218bda5 | |
|
|
131605070a | |
|
|
ac4596974d | |
|
|
7d9a6a3f9d | |
|
|
7f9c84ebe8 | |
|
|
569dd8afb1 | |
|
|
bf818a6656 | |
|
|
f32b11ba12 | |
|
|
6c9f6e8057 | |
|
|
6b16b85203 | |
|
|
4597d5db91 | |
|
|
9c79f5d135 | |
|
|
0e27fa59d4 | |
|
|
4ae591a2c7 | |
|
|
af9ebf7034 | |
|
|
b04a0c99a4 | |
|
|
10a2c16a6d | |
|
|
622fc44645 | |
|
|
3f810a856c | |
|
|
c47203b888 | |
|
|
c99d55e0cb | |
|
|
bb042b46ac | |
|
|
084e9449dc | |
|
|
2af3710757 | |
|
|
894f65f754 | |
|
|
166aeb631e | |
|
|
2d6f9b083f | |
|
|
7a9434414d | |
|
|
b302796e70 | |
|
|
76cdb48a3e | |
|
|
b32022c20c | |
|
|
b785793e41 | |
|
|
bfb6e2f461 | |
|
|
38e3d248fb | |
|
|
be3b2558d1 | |
|
|
d57f20dc84 | |
|
|
00bf57d243 | |
|
|
586a458c7a | |
|
|
2dd886e285 | |
|
|
7cdaa10d46 | |
|
|
9ea29fe84c | |
|
|
054a48c184 | |
|
|
c964c49c58 | |
|
|
10a112489d | |
|
|
859cf75ad3 | |
|
|
3c97164cf2 | |
|
|
8121f8f40f | |
|
|
072928b147 | |
|
|
5af8b5d547 | |
|
|
5e3a805671 | |
|
|
ef14a56a5c | |
|
|
2f0924c85b | |
|
|
4183fec4ab | |
|
|
ce65e9dd69 | |
|
|
d7337e5f81 | |
|
|
547b74c9b2 | |
|
|
d427d24ca4 | |
|
|
668bf9a226 | |
|
|
11404e545e | |
|
|
5f48109230 | |
|
|
dad8430040 | |
|
|
0111b8736a | |
|
|
1ea098f4b4 | |
|
|
366fade8d3 | |
|
|
db7bd0e94e | |
|
|
606c88aa6b | |
|
|
55629f2b39 | |
|
|
ab404d491d | |
|
|
0f2991cbaf | |
|
|
34ed9cb187 | |
|
|
67e6184a75 | |
|
|
2133d0552c | |
|
|
314c31db34 |
|
|
@ -15,6 +15,7 @@ on:
|
|||
- packages/sw/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-mahjong/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/shared/eslint.config.js
|
||||
- .github/workflows/lint.yml
|
||||
|
|
@ -29,6 +30,7 @@ on:
|
|||
- packages/sw/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-mahjong/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/shared/eslint.config.js
|
||||
- .github/workflows/lint.yml
|
||||
|
|
@ -64,6 +66,7 @@ jobs:
|
|||
- sw
|
||||
- misskey-js
|
||||
- misskey-bubble-game
|
||||
- misskey-mahjong
|
||||
- misskey-reversi
|
||||
env:
|
||||
eslint-cache-version: v1
|
||||
|
|
|
|||
|
|
@ -54,55 +54,110 @@ jobs:
|
|||
BASE_MEMORY=$(cat ./artifacts/memory-base.json)
|
||||
HEAD_MEMORY=$(cat ./artifacts/memory-head.json)
|
||||
|
||||
BASE_RSS=$(echo "$BASE_MEMORY" | jq -r '.memory.rss // 0')
|
||||
HEAD_RSS=$(echo "$HEAD_MEMORY" | jq -r '.memory.rss // 0')
|
||||
variation() {
|
||||
calc() {
|
||||
BASE=$(echo "$BASE_MEMORY" | jq -r ".${1}.${2} // 0")
|
||||
HEAD=$(echo "$HEAD_MEMORY" | jq -r ".${1}.${2} // 0")
|
||||
|
||||
# Calculate difference
|
||||
if [ "$BASE_RSS" -gt 0 ] && [ "$HEAD_RSS" -gt 0 ]; then
|
||||
DIFF=$((HEAD_RSS - BASE_RSS))
|
||||
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE_RSS" | bc)
|
||||
DIFF=$((HEAD - BASE))
|
||||
if [ "$BASE" -gt 0 ]; then
|
||||
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc)
|
||||
else
|
||||
DIFF_PERCENT=0
|
||||
fi
|
||||
|
||||
# Convert to MB for readability
|
||||
BASE_MB=$(echo "scale=2; $BASE_RSS / 1048576" | bc)
|
||||
HEAD_MB=$(echo "scale=2; $HEAD_RSS / 1048576" | bc)
|
||||
DIFF_MB=$(echo "scale=2; $DIFF / 1048576" | bc)
|
||||
# Convert KB to MB for readability
|
||||
BASE_MB=$(echo "scale=2; $BASE / 1024" | bc)
|
||||
HEAD_MB=$(echo "scale=2; $HEAD / 1024" | bc)
|
||||
DIFF_MB=$(echo "scale=2; $DIFF / 1024" | bc)
|
||||
|
||||
echo "base_mb=$BASE_MB" >> "$GITHUB_OUTPUT"
|
||||
echo "head_mb=$HEAD_MB" >> "$GITHUB_OUTPUT"
|
||||
echo "diff_mb=$DIFF_MB" >> "$GITHUB_OUTPUT"
|
||||
echo "diff_percent=$DIFF_PERCENT" >> "$GITHUB_OUTPUT"
|
||||
echo "has_data=true" >> "$GITHUB_OUTPUT"
|
||||
JSON=$(jq -c -n \
|
||||
--argjson base "$BASE_MB" \
|
||||
--argjson head "$HEAD_MB" \
|
||||
--argjson diff "$DIFF_MB" \
|
||||
--argjson diff_percent "$DIFF_PERCENT" \
|
||||
'{base: $base, head: $head, diff: $diff, diff_percent: $diff_percent}')
|
||||
|
||||
# Determine if this is a significant change (more than 5% increase)
|
||||
if [ "$(echo "$DIFF_PERCENT > 5" | bc)" -eq 1 ]; then
|
||||
echo "significant_increase=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "significant_increase=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "has_data=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "$JSON"
|
||||
}
|
||||
|
||||
JSON=$(jq -c -n \
|
||||
--argjson VmRSS "$(calc $1 VmRSS)" \
|
||||
--argjson VmHWM "$(calc $1 VmHWM)" \
|
||||
--argjson VmSize "$(calc $1 VmSize)" \
|
||||
--argjson VmData "$(calc $1 VmData)" \
|
||||
'{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}')
|
||||
|
||||
echo "$JSON"
|
||||
}
|
||||
|
||||
JSON=$(jq -c -n \
|
||||
--argjson beforeGc "$(variation beforeGc)" \
|
||||
--argjson afterGc "$(variation afterGc)" \
|
||||
--argjson afterRequest "$(variation afterRequest)" \
|
||||
'{beforeGc: $beforeGc, afterGc: $afterGc, afterRequest: $afterRequest}')
|
||||
|
||||
echo "res=$JSON" >> "$GITHUB_OUTPUT"
|
||||
- id: build-comment
|
||||
name: Build memory comment
|
||||
env:
|
||||
RES: ${{ steps.compare.outputs.res }}
|
||||
run: |
|
||||
HEADER="## Backend Memory Usage Comparison"
|
||||
HEADER="## Backend memory usage comparison"
|
||||
FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
|
||||
|
||||
echo "$HEADER" > ./output.md
|
||||
echo >> ./output.md
|
||||
|
||||
if [ "${{ steps.compare.outputs.has_data }}" == "true" ]; then
|
||||
echo "| Metric | base | head | Diff |" >> ./output.md
|
||||
echo "|--------|------|------|------|" >> ./output.md
|
||||
echo "| RSS | ${{ steps.compare.outputs.base_mb }} MB | ${{ steps.compare.outputs.head_mb }} MB | ${{ steps.compare.outputs.diff_mb }} MB (${{ steps.compare.outputs.diff_percent }}%) |" >> ./output.md
|
||||
echo >> ./output.md
|
||||
table() {
|
||||
echo "| Metric | base (MB) | head (MB) | Diff (MB) | Diff (%) |" >> ./output.md
|
||||
echo "|--------|------:|------:|------:|------:|" >> ./output.md
|
||||
|
||||
if [ "${{ steps.compare.outputs.significant_increase }}" == "true" ]; then
|
||||
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
|
||||
echo >> ./output.md
|
||||
fi
|
||||
else
|
||||
echo "Could not retrieve memory usage data." >> ./output.md
|
||||
line() {
|
||||
METRIC=$2
|
||||
BASE=$(echo "$RES" | jq -r ".${1}.${2}.base")
|
||||
HEAD=$(echo "$RES" | jq -r ".${1}.${2}.head")
|
||||
DIFF=$(echo "$RES" | jq -r ".${1}.${2}.diff")
|
||||
DIFF_PERCENT=$(echo "$RES" | jq -r ".${1}.${2}.diff_percent")
|
||||
|
||||
if (( $(echo "$DIFF_PERCENT > 0" | bc -l) )); then
|
||||
DIFF="+$DIFF"
|
||||
DIFF_PERCENT="+$DIFF_PERCENT"
|
||||
fi
|
||||
|
||||
# highlight VmRSS
|
||||
if [ "$2" = "VmRSS" ]; then
|
||||
METRIC="**${METRIC}**"
|
||||
BASE="**${BASE}**"
|
||||
HEAD="**${HEAD}**"
|
||||
DIFF="**${DIFF}**"
|
||||
DIFF_PERCENT="**${DIFF_PERCENT}**"
|
||||
fi
|
||||
|
||||
echo "| ${METRIC} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB | ${DIFF_PERCENT}% |" >> ./output.md
|
||||
}
|
||||
|
||||
line $1 VmRSS
|
||||
line $1 VmHWM
|
||||
line $1 VmSize
|
||||
line $1 VmData
|
||||
}
|
||||
|
||||
echo "### Before GC" >> ./output.md
|
||||
table beforeGc
|
||||
echo >> ./output.md
|
||||
|
||||
echo "### After GC" >> ./output.md
|
||||
table afterGc
|
||||
echo >> ./output.md
|
||||
|
||||
echo "### After Request" >> ./output.md
|
||||
table afterRequest
|
||||
echo >> ./output.md
|
||||
|
||||
# Determine if this is a significant change (more than 5% increase)
|
||||
if [ "$(echo "$RES" | jq -r '.afterGc.VmRSS.diff_percent | tonumber > 5')" = "true" ]; then
|
||||
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
|
||||
echo >> ./output.md
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -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-reversi build
|
||||
run: pnpm -F misskey-js -F misskey-bubble-game -F misskey-mahjong -F misskey-reversi build
|
||||
- name: Build storybook
|
||||
run: pnpm --filter frontend build-storybook
|
||||
- name: Publish to Chromatic
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ COPY --link ["packages/sw/package.json", "./packages/sw/"]
|
|||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
|
||||
COPY --link ["packages/misskey-mahjong/package.json", "./packages/misskey-mahjong/"]
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
|
|||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
|
||||
COPY --link ["packages/misskey-mahjong/package.json", "./packages/misskey-mahjong/"]
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
|
|
@ -97,10 +99,12 @@ COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/nod
|
|||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-reversi/node_modules ./packages/misskey-reversi/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-bubble-game/node_modules ./packages/misskey-bubble-game/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-mahjong/node_modules ./packages/misskey-mahjong/node_modules
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/built ./packages/misskey-js/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-mahjong/built ./packages/misskey-mahjong/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/src-js ./packages/backend/src-js
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
|
||||
|
|
|
|||
|
|
@ -3180,6 +3180,79 @@ _reversi:
|
|||
showBoardLabels: "盤面に行・列番号を表示"
|
||||
useAvatarAsStone: "石をアイコンにする"
|
||||
|
||||
_mahjong:
|
||||
mahjong: "麻雀"
|
||||
joinRoom: "ルームに参加"
|
||||
createRoom: "ルームを作成"
|
||||
ready: "準備完了"
|
||||
cancelReady: "準備を再開"
|
||||
leave: "退室"
|
||||
addCpu: "CPUを追加"
|
||||
east: "東"
|
||||
south: "南"
|
||||
west: "西"
|
||||
north: "北"
|
||||
dora: "ドラ"
|
||||
redDora: "赤ドラ"
|
||||
fan: "飜"
|
||||
_fanNames:
|
||||
mangan: "満貫"
|
||||
haneman: "跳満"
|
||||
baiman: "倍満"
|
||||
sanbaiman: "三倍満"
|
||||
yakuman: "役満"
|
||||
kazoeyakuman: "数え役満"
|
||||
_yakus:
|
||||
"riichi": "立直"
|
||||
"ippatsu": "一発"
|
||||
"tsumo": "門前清自摸和"
|
||||
"tanyao": "断么"
|
||||
"pinfu": "平和"
|
||||
"iipeko": "一盃口"
|
||||
"field-wind-e": "東"
|
||||
"field-wind-s": "南"
|
||||
"seat-wind-e": "東"
|
||||
"seat-wind-s": "南"
|
||||
"seat-wind-w": "西"
|
||||
"seat-wind-n": "北"
|
||||
"white": "白"
|
||||
"green": "發"
|
||||
"red": "中"
|
||||
"rinshan": "嶺上開花"
|
||||
"chankan": "搶槓"
|
||||
"haitei": "海底摸月"
|
||||
"hotei": "河底撈魚"
|
||||
"sanshoku-dojun": "三色同順"
|
||||
"sanshoku-doko": "三色同刻"
|
||||
"ittsu": "一気通貫"
|
||||
"chanta": "混全帯么九"
|
||||
"chitoitsu": "七対子"
|
||||
"toitoi": "対々"
|
||||
"sananko": "三暗刻"
|
||||
"honroto": "混老頭"
|
||||
"sankantsu": "三槓子"
|
||||
"shosangen": "小三元"
|
||||
"double-riichi": "ダブル立直"
|
||||
"honitsu": "混一色"
|
||||
"junchan": "清全帯么九"
|
||||
"ryampeko": "ニ盃口"
|
||||
"chinitsu": "清一色"
|
||||
"kokushi": "国士無双"
|
||||
"kokushi-13": "国士無双十三面待"
|
||||
"suanko": "四暗刻"
|
||||
"suanko-tanki": "四暗刻単騎待"
|
||||
"daisangen": "大三元"
|
||||
"tsuiso": "字一色"
|
||||
"shosushi": "小四喜"
|
||||
"daisushi": "大四喜"
|
||||
"ryuiso": "緑一色"
|
||||
"chinroto": "清老頭"
|
||||
"sukantsu": "四槓子"
|
||||
"churen": "九蓮宝燈"
|
||||
"churen-9": "九連宝灯九面待"
|
||||
"tenho": "天和"
|
||||
"chiho": "地和"
|
||||
|
||||
_offlineScreen:
|
||||
title: "オフライン - サーバーに接続できません"
|
||||
header: "サーバーに接続できません"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"packages/i18n",
|
||||
"packages/misskey-reversi",
|
||||
"packages/misskey-bubble-game",
|
||||
"packages/misskey-mahjong",
|
||||
"packages/icons-subsetter",
|
||||
"packages/frontend-shared",
|
||||
"packages/frontend-builder",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class Mahjong1706234054207 {
|
||||
name = 'Mahjong1706234054207'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "mahjong_game" ("id" character varying(32) NOT NULL, "startedAt" TIMESTAMP WITH TIME ZONE, "endedAt" TIMESTAMP WITH TIME ZONE, "user1Id" character varying(32), "user2Id" character varying(32), "user3Id" character varying(32), "user4Id" character varying(32), "isEnded" boolean NOT NULL DEFAULT false, "winnerId" character varying(32), "timeLimitForEachTurn" smallint NOT NULL DEFAULT '90', "logs" jsonb NOT NULL DEFAULT '[]', CONSTRAINT "PK_77db54c0a9785d387e3fbbdd2f0" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_b98c78761a845b69e6540401264" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_f17b0ba519ae28f188a7915ad6f" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_64314ffd3cb59475b0d06330058" FOREIGN KEY ("user3Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_58a75f1ea2a810ae3986f72a0e3" FOREIGN KEY ("user4Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_58a75f1ea2a810ae3986f72a0e3"`);
|
||||
await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_64314ffd3cb59475b0d06330058"`);
|
||||
await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_f17b0ba519ae28f188a7915ad6f"`);
|
||||
await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_b98c78761a845b69e6540401264"`);
|
||||
await queryRunner.query(`DROP TABLE "mahjong_game"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +134,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { fork } from 'node:child_process';
|
|||
import { setTimeout } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -22,6 +24,35 @@ const SAMPLE_COUNT = 3; // Number of samples to measure
|
|||
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
|
||||
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
|
||||
|
||||
const keys = {
|
||||
VmPeak: 0,
|
||||
VmSize: 0,
|
||||
VmHWM: 0,
|
||||
VmRSS: 0,
|
||||
VmData: 0,
|
||||
VmStk: 0,
|
||||
VmExe: 0,
|
||||
VmLib: 0,
|
||||
VmPTE: 0,
|
||||
VmSwap: 0,
|
||||
};
|
||||
|
||||
async function getMemoryUsage(pid) {
|
||||
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
|
||||
|
||||
const result = {};
|
||||
for (const key of Object.keys(keys)) {
|
||||
const match = status.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
|
||||
if (match) {
|
||||
result[key] = parseInt(match[1], 10);
|
||||
} else {
|
||||
throw new Error(`Failed to parse ${key} from /proc/${pid}/status`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function measureMemory() {
|
||||
// Start the Misskey backend server using fork to enable IPC
|
||||
const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), ['expose-gc'], {
|
||||
|
|
@ -30,9 +61,9 @@ async function measureMemory() {
|
|||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
MK_DISABLE_CLUSTERING: '1',
|
||||
MK_FORCE_GC: '1',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
execArgv: [...process.execArgv, '--expose-gc'],
|
||||
});
|
||||
|
||||
let serverReady = false;
|
||||
|
|
@ -58,6 +89,40 @@ async function measureMemory() {
|
|||
process.stderr.write(`[server error] ${err}\n`);
|
||||
});
|
||||
|
||||
async function triggerGc() {
|
||||
const ok = new Promise((resolve) => {
|
||||
serverProcess.once('message', (message) => {
|
||||
if (message === 'gc ok') resolve();
|
||||
});
|
||||
});
|
||||
|
||||
serverProcess.send('gc');
|
||||
|
||||
await ok;
|
||||
|
||||
await setTimeout(1000);
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
host: 'localhost',
|
||||
port: 61812,
|
||||
path: '/api/meta',
|
||||
method: 'POST',
|
||||
}, (res) => {
|
||||
res.on('data', () => { });
|
||||
res.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for server to be ready or timeout
|
||||
const startupStartTime = Date.now();
|
||||
while (!serverReady) {
|
||||
|
|
@ -74,41 +139,23 @@ async function measureMemory() {
|
|||
// Wait for memory to settle
|
||||
await setTimeout(MEMORY_SETTLE_TIME);
|
||||
|
||||
// Get memory usage from the server process via /proc
|
||||
const pid = serverProcess.pid;
|
||||
let memoryInfo;
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises');
|
||||
const beforeGc = await getMemoryUsage(pid);
|
||||
|
||||
// Read /proc/[pid]/status for detailed memory info
|
||||
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
|
||||
const vmRssMatch = status.match(/VmRSS:\s+(\d+)\s+kB/);
|
||||
const vmDataMatch = status.match(/VmData:\s+(\d+)\s+kB/);
|
||||
const vmSizeMatch = status.match(/VmSize:\s+(\d+)\s+kB/);
|
||||
await triggerGc();
|
||||
|
||||
memoryInfo = {
|
||||
rss: vmRssMatch ? parseInt(vmRssMatch[1], 10) * 1024 : null,
|
||||
heapUsed: vmDataMatch ? parseInt(vmDataMatch[1], 10) * 1024 : null,
|
||||
vmSize: vmSizeMatch ? parseInt(vmSizeMatch[1], 10) * 1024 : null,
|
||||
};
|
||||
} catch (err) {
|
||||
// Fallback: use ps command
|
||||
process.stderr.write(`Warning: Could not read /proc/${pid}/status: ${err}\n`);
|
||||
const afterGc = await getMemoryUsage(pid);
|
||||
|
||||
const { execSync } = await import('node:child_process');
|
||||
try {
|
||||
const ps = execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' });
|
||||
const rssKb = parseInt(ps.trim(), 10);
|
||||
memoryInfo = {
|
||||
rss: rssKb * 1024,
|
||||
heapUsed: null,
|
||||
vmSize: null,
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Failed to get memory usage via ps command');
|
||||
}
|
||||
}
|
||||
// create some http requests to simulate load
|
||||
const REQUEST_COUNT = 10;
|
||||
await Promise.all(
|
||||
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
);
|
||||
|
||||
await triggerGc();
|
||||
|
||||
const afterRequest = await getMemoryUsage(pid);
|
||||
|
||||
// Stop the server
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
|
@ -131,7 +178,9 @@ async function measureMemory() {
|
|||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
memory: memoryInfo,
|
||||
beforeGc,
|
||||
afterGc,
|
||||
afterRequest,
|
||||
};
|
||||
|
||||
return result;
|
||||
|
|
@ -146,23 +195,27 @@ async function main() {
|
|||
}
|
||||
|
||||
// Calculate averages
|
||||
const avgMemory = {
|
||||
rss: 0,
|
||||
heapUsed: 0,
|
||||
vmSize: 0,
|
||||
};
|
||||
const beforeGc = structuredClone(keys);
|
||||
const afterGc = structuredClone(keys);
|
||||
const afterRequest = structuredClone(keys);
|
||||
for (const res of results) {
|
||||
avgMemory.rss += res.memory.rss ?? 0;
|
||||
avgMemory.heapUsed += res.memory.heapUsed ?? 0;
|
||||
avgMemory.vmSize += res.memory.vmSize ?? 0;
|
||||
for (const key of Object.keys(keys)) {
|
||||
beforeGc[key] += res.beforeGc[key];
|
||||
afterGc[key] += res.afterGc[key];
|
||||
afterRequest[key] += res.afterRequest[key];
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(keys)) {
|
||||
beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT);
|
||||
afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT);
|
||||
afterRequest[key] = Math.round(afterRequest[key] / SAMPLE_COUNT);
|
||||
}
|
||||
avgMemory.rss = Math.round(avgMemory.rss / SAMPLE_COUNT);
|
||||
avgMemory.heapUsed = Math.round(avgMemory.heapUsed / SAMPLE_COUNT);
|
||||
avgMemory.vmSize = Math.round(avgMemory.vmSize / SAMPLE_COUNT);
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
memory: avgMemory,
|
||||
beforeGc,
|
||||
afterGc,
|
||||
afterRequest,
|
||||
};
|
||||
|
||||
// Output as JSON to stdout
|
||||
|
|
|
|||
|
|
@ -86,9 +86,17 @@ if (!envOption.disableClustering) {
|
|||
ev.mount();
|
||||
}
|
||||
|
||||
if (envOption.forceGc && global.gc != null) {
|
||||
global.gc();
|
||||
}
|
||||
process.on('message', msg => {
|
||||
if (msg === 'gc') {
|
||||
if (global.gc != null) {
|
||||
logger.info('Manual GC triggered');
|
||||
global.gc();
|
||||
if (process.send != null) process.send('gc ok');
|
||||
} else {
|
||||
logger.warn('Manual GC requested but gc is not available. Start the process with --expose-gc to enable this feature.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
readyRef.value = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import { ChannelFollowingService } from './ChannelFollowingService.js';
|
|||
import { ChatService } from './ChatService.js';
|
||||
import { RegistryApiService } from './RegistryApiService.js';
|
||||
import { ReversiService } from './ReversiService.js';
|
||||
import { MahjongService } from './MahjongService.js';
|
||||
import { PageService } from './PageService.js';
|
||||
|
||||
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
||||
|
|
@ -228,6 +229,7 @@ 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 };
|
||||
|
|
@ -381,6 +383,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
MahjongService,
|
||||
PageService,
|
||||
|
||||
ChartLoggerService,
|
||||
|
|
@ -531,6 +534,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$MahjongService,
|
||||
$PageService,
|
||||
|
||||
$ChartLoggerService,
|
||||
|
|
@ -681,6 +685,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
MahjongService,
|
||||
PageService,
|
||||
|
||||
FederationChart,
|
||||
|
|
@ -829,6 +834,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$MahjongService,
|
||||
$PageService,
|
||||
|
||||
$FederationChart,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as Reversi from 'misskey-reversi';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
import type { MiChannel } from '@/models/Channel.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiUserProfile } from '@/models/UserProfile.js';
|
||||
|
|
@ -201,6 +202,78 @@ export interface ReversiGameEventTypes {
|
|||
userId: MiUser['id'];
|
||||
};
|
||||
}
|
||||
|
||||
export interface MahjongRoomEventTypes {
|
||||
joined: {
|
||||
index: number;
|
||||
user: Packed<'UserLite'> | null;
|
||||
};
|
||||
changeReadyStates: {
|
||||
user1: boolean;
|
||||
user2: boolean;
|
||||
user3: boolean;
|
||||
user4: boolean;
|
||||
};
|
||||
started: {
|
||||
room: Packed<'MahjongRoomDetailed'>;
|
||||
};
|
||||
nextKyoku: {
|
||||
room: Packed<'MahjongRoomDetailed'>;
|
||||
};
|
||||
tsumo: {
|
||||
house: Mmj.House;
|
||||
tile: number;
|
||||
};
|
||||
dahai: {
|
||||
house: Mmj.House;
|
||||
tile: number;
|
||||
riichi: boolean;
|
||||
};
|
||||
dahaiAndTsumo: {
|
||||
dahaiHouse: Mmj.House;
|
||||
dahaiTile: number;
|
||||
tsumoTile: number;
|
||||
riichi: boolean;
|
||||
};
|
||||
ponned: {
|
||||
caller: Mmj.House;
|
||||
callee: Mmj.House;
|
||||
tiles: readonly [number, number, number];
|
||||
};
|
||||
kanned: {
|
||||
caller: Mmj.House;
|
||||
callee: Mmj.House;
|
||||
tiles: readonly [number, number, number, number];
|
||||
rinsyan: number;
|
||||
};
|
||||
ciied: {
|
||||
caller: Mmj.House;
|
||||
callee: Mmj.House;
|
||||
tiles: readonly [number, number, number];
|
||||
};
|
||||
ronned: {
|
||||
callers: Mmj.House[];
|
||||
callee: Mmj.House;
|
||||
handTiles: Record<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 }ユニオンを定義
|
||||
|
|
@ -320,6 +393,10 @@ export type GlobalEvents = {
|
|||
name: `reversiGameStream:${MiReversiGame['id']}`;
|
||||
payload: EventTypesToEventPayload<ReversiGameEventTypes>;
|
||||
};
|
||||
mahjongRoom: {
|
||||
name: `mahjongRoomStream:${string}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<MahjongRoomEventTypes>>;
|
||||
};
|
||||
};
|
||||
|
||||
// API event definitions
|
||||
|
|
@ -429,4 +506,9 @@ export class GlobalEventService {
|
|||
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
|
||||
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishMahjongRoomStream<K extends keyof MahjongRoomEventTypes>(roomId: string, type: K, value?: MahjongRoomEventTypes[K]): void {
|
||||
this.publish(`mahjongRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,734 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { IsNull, LessThan, MoreThan } from 'typeorm';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
import type {
|
||||
MiMahjongGame,
|
||||
MahjongGamesRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { Serialized } from '@/types.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
|
||||
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
|
||||
const CALL_AND_RON_ASKING_TIMEOUT_MS = 1000 * 10; // 10sec
|
||||
const TURN_TIMEOUT_MS = 1000 * 30; // 30sec
|
||||
const NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS = 1000 * 15; // 15sec
|
||||
|
||||
type Room = {
|
||||
id: string;
|
||||
user1Id: MiUser['id'];
|
||||
user2Id: MiUser['id'] | null;
|
||||
user3Id: MiUser['id'] | null;
|
||||
user4Id: MiUser['id'] | null;
|
||||
user1: Packed<'UserLite'> | null;
|
||||
user2: Packed<'UserLite'> | null;
|
||||
user3: Packed<'UserLite'> | null;
|
||||
user4: Packed<'UserLite'> | null;
|
||||
user1Ai?: boolean;
|
||||
user2Ai?: boolean;
|
||||
user3Ai?: boolean;
|
||||
user4Ai?: boolean;
|
||||
user1Ready: boolean;
|
||||
user2Ready: boolean;
|
||||
user3Ready: boolean;
|
||||
user4Ready: boolean;
|
||||
user1Offline?: boolean;
|
||||
user2Offline?: boolean;
|
||||
user3Offline?: boolean;
|
||||
user4Offline?: boolean;
|
||||
isStarted?: boolean;
|
||||
timeLimitForEachTurn: number;
|
||||
|
||||
gameState?: Mmj.MasterState;
|
||||
};
|
||||
|
||||
type CallingAnswers = {
|
||||
pon: null | boolean;
|
||||
cii: null | false | 'x__' | '_x_' | '__x';
|
||||
kan: null | boolean;
|
||||
ron: {
|
||||
e: null | boolean;
|
||||
s: null | boolean;
|
||||
w: null | boolean;
|
||||
n: null | boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type NextKyokuConfirmation = {
|
||||
user1: boolean;
|
||||
user2: boolean;
|
||||
user3: boolean;
|
||||
user4: boolean;
|
||||
};
|
||||
|
||||
function getUserIdOfHouse(room: Room, mj: Mmj.MasterGameEngine, house: Mmj.House): MiUser['id'] {
|
||||
return mj.user1House === house ? room.user1Id : mj.user2House === house ? room.user2Id : mj.user3House === house ? room.user3Id : room.user4Id;
|
||||
}
|
||||
|
||||
function getHouseOfUserId(room: Room, mj: Mmj.MasterGameEngine, userId: MiUser['id']): Mmj.House {
|
||||
return userId === room.user1Id ? mj.user1House : userId === room.user2Id ? mj.user2House : userId === room.user3Id ? mj.user3House : mj.user4House;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MahjongService implements OnApplicationShutdown, OnModuleInit {
|
||||
private notificationService: NotificationService;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
//@Inject(DI.mahjongGamesRepository)
|
||||
//private mahjongGamesRepository: MahjongGamesRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
async onModuleInit() {
|
||||
this.notificationService = this.moduleRef.get(NotificationService.name);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async saveRoom(room: Room) {
|
||||
await this.redisClient.set(`mahjong:room:${room.id}`, JSON.stringify(room), 'EX', 60 * 30);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createRoom(user: MiUser): Promise<Room> {
|
||||
const room: Room = {
|
||||
id: this.idService.gen(),
|
||||
user1Id: user.id,
|
||||
user2Id: null,
|
||||
user3Id: null,
|
||||
user4Id: null,
|
||||
user1: await this.userEntityService.pack(user),
|
||||
user1Ready: false,
|
||||
user2Ready: false,
|
||||
user3Ready: false,
|
||||
user4Ready: false,
|
||||
timeLimitForEachTurn: 30,
|
||||
};
|
||||
await this.saveRoom(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getRoom(id: Room['id']): Promise<Room | null> {
|
||||
const room = await this.redisClient.get(`mahjong:room:${id}`);
|
||||
if (!room) return null;
|
||||
const parsed = JSON.parse(room);
|
||||
return {
|
||||
...parsed,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async joinRoom(roomId: Room['id'], user: MiUser): Promise<Room | null> {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
if (room.user1Id === user.id) return room;
|
||||
if (room.user2Id === user.id) return room;
|
||||
if (room.user3Id === user.id) return room;
|
||||
if (room.user4Id === user.id) return room;
|
||||
if (room.user2Id === null) {
|
||||
room.user2Id = user.id;
|
||||
room.user2 = await this.userEntityService.pack(user);
|
||||
await this.saveRoom(room);
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 2, user: room.user2 });
|
||||
return room;
|
||||
}
|
||||
if (room.user3Id === null) {
|
||||
room.user3Id = user.id;
|
||||
room.user3 = await this.userEntityService.pack(user);
|
||||
await this.saveRoom(room);
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 3, user: room.user3 });
|
||||
return room;
|
||||
}
|
||||
if (room.user4Id === null) {
|
||||
room.user4Id = user.id;
|
||||
room.user4 = await this.userEntityService.pack(user);
|
||||
await this.saveRoom(room);
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 4, user: room.user4 });
|
||||
return room;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async addAi(roomId: Room['id'], user: MiUser): Promise<Room | null> {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
if (room.user1Id !== user.id) throw new Error('access denied');
|
||||
|
||||
if (room.user2Id == null && !room.user2Ai) {
|
||||
room.user2Ai = true;
|
||||
room.user2Ready = true;
|
||||
await this.saveRoom(room);
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 2, user: null });
|
||||
return room;
|
||||
}
|
||||
if (room.user3Id == null && !room.user3Ai) {
|
||||
room.user3Ai = true;
|
||||
room.user3Ready = true;
|
||||
await this.saveRoom(room);
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 3, user: null });
|
||||
return room;
|
||||
}
|
||||
if (room.user4Id == null && !room.user4Ai) {
|
||||
room.user4Ai = true;
|
||||
room.user4Ready = true;
|
||||
await this.saveRoom(room);
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 4, user: null });
|
||||
return room;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async leaveRoom(roomId: Room['id'], user: MiUser): Promise<Room | null> {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
if (room.user1Id === user.id) {
|
||||
room.user1Id = null;
|
||||
room.user1 = null;
|
||||
await this.saveRoom(room);
|
||||
return room;
|
||||
}
|
||||
if (room.user2Id === user.id) {
|
||||
room.user2Id = null;
|
||||
room.user2 = null;
|
||||
await this.saveRoom(room);
|
||||
return room;
|
||||
}
|
||||
if (room.user3Id === user.id) {
|
||||
room.user3Id = null;
|
||||
room.user3 = null;
|
||||
await this.saveRoom(room);
|
||||
return room;
|
||||
}
|
||||
if (room.user4Id === user.id) {
|
||||
room.user4Id = null;
|
||||
room.user4 = null;
|
||||
await this.saveRoom(room);
|
||||
return room;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async changeReadyState(roomId: Room['id'], user: MiUser, ready: boolean): Promise<void> {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
if (room.user1Id === user.id) {
|
||||
room.user1Ready = ready;
|
||||
await this.saveRoom(room);
|
||||
}
|
||||
if (room.user2Id === user.id) {
|
||||
room.user2Ready = ready;
|
||||
await this.saveRoom(room);
|
||||
}
|
||||
if (room.user3Id === user.id) {
|
||||
room.user3Ready = ready;
|
||||
await this.saveRoom(room);
|
||||
}
|
||||
if (room.user4Id === user.id) {
|
||||
room.user4Ready = ready;
|
||||
await this.saveRoom(room);
|
||||
}
|
||||
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'changeReadyStates', {
|
||||
user1: room.user1Ready,
|
||||
user2: room.user2Ready,
|
||||
user3: room.user3Ready,
|
||||
user4: room.user4Ready,
|
||||
});
|
||||
|
||||
if (room.user1Ready && room.user2Ready && room.user3Ready && room.user4Ready) {
|
||||
await this.startGame(room);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async startGame(room: Room) {
|
||||
if (!room.user1Ready || !room.user2Ready || !room.user3Ready || !room.user4Ready) {
|
||||
throw new Error('Not ready');
|
||||
}
|
||||
|
||||
room.gameState = Mmj.MasterGameEngine.createInitialState();
|
||||
room.isStarted = true;
|
||||
await this.saveRoom(room);
|
||||
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'started', { room: room });
|
||||
|
||||
this.kyokuStarted(room);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private kyokuStarted(room: Room) {
|
||||
const mj = new Mmj.MasterGameEngine(room.gameState);
|
||||
|
||||
this.waitForTurn(room, mj.turn, mj);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async answer(room: Room, mj: Mmj.MasterGameEngine, answers: CallingAnswers) {
|
||||
const res = mj.commit_resolveCallingInterruption({
|
||||
pon: answers.pon ?? false,
|
||||
cii: answers.cii ?? false,
|
||||
kan: answers.kan ?? false,
|
||||
ron: [...(answers.ron.e ? ['e'] : []), ...(answers.ron.s ? ['s'] : []), ...(answers.ron.w ? ['w'] : []), ...(answers.ron.n ? ['n'] : [])] as Mmj.House[],
|
||||
});
|
||||
room.gameState = mj.getState();
|
||||
await this.saveRoom(room);
|
||||
|
||||
switch (res.type) {
|
||||
case 'tsumo':
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'tsumo', { house: res.house, tile: res.tile });
|
||||
this.waitForTurn(room, res.turn, mj);
|
||||
break;
|
||||
case 'ponned':
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'ponned', { caller: res.caller, callee: res.callee, tiles: res.tiles });
|
||||
this.waitForTurn(room, res.turn, mj);
|
||||
break;
|
||||
case 'kanned':
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'kanned', { caller: res.caller, callee: res.callee, tiles: res.tiles, rinsyan: res.rinsyan });
|
||||
this.waitForTurn(room, res.turn, mj);
|
||||
break;
|
||||
case 'ciied':
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'ciied', { caller: res.caller, callee: res.callee, tiles: res.tiles });
|
||||
this.waitForTurn(room, res.turn, mj);
|
||||
break;
|
||||
case 'ronned':
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'ronned', {
|
||||
callers: res.callers,
|
||||
callee: res.callee,
|
||||
handTiles: {
|
||||
e: mj.handTiles.e,
|
||||
s: mj.handTiles.s,
|
||||
w: mj.handTiles.w,
|
||||
n: mj.handTiles.n,
|
||||
},
|
||||
});
|
||||
this.endKyoku(room, mj);
|
||||
break;
|
||||
case 'ryuukyoku':
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'ryuukyoku', {
|
||||
});
|
||||
this.endKyoku(room, mj);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async endKyoku(room: Room, mj: Mmj.MasterGameEngine) {
|
||||
const confirmation: NextKyokuConfirmation = {
|
||||
user1: false,
|
||||
user2: false,
|
||||
user3: false,
|
||||
user4: false,
|
||||
};
|
||||
this.redisClient.set(`mahjong:gameNextKyokuConfirmation:${room.id}`, JSON.stringify(confirmation));
|
||||
const waitingStartedAt = Date.now();
|
||||
const interval = setInterval(async () => {
|
||||
const confirmationRaw = await this.redisClient.get(`mahjong:gameNextKyokuConfirmation:${room.id}`);
|
||||
if (confirmationRaw == null) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
const confirmation = JSON.parse(confirmationRaw) as NextKyokuConfirmation;
|
||||
const allConfirmed = confirmation.user1 && confirmation.user2 && confirmation.user3 && confirmation.user4;
|
||||
if (allConfirmed || (Date.now() - waitingStartedAt > NEXT_KYOKU_CONFIRMATION_TIMEOUT_MS)) {
|
||||
await this.redisClient.del(`mahjong:gameNextKyokuConfirmation:${room.id}`);
|
||||
clearInterval(interval);
|
||||
this.nextKyoku(room, mj);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async nextKyoku(room: Room, mj: Mmj.MasterGameEngine) {
|
||||
const res = mj.commit_nextKyoku();
|
||||
room.gameState = mj.getState();
|
||||
await this.saveRoom(room);
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'nextKyoku', {
|
||||
room: room,
|
||||
});
|
||||
this.kyokuStarted(room);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async dahai(room: Room, mj: Mmj.MasterGameEngine, house: Mmj.House, tile: Mmj.TileId, riichi = false) {
|
||||
const res = mj.commit_dahai(house, tile, riichi);
|
||||
room.gameState = mj.getState();
|
||||
await this.saveRoom(room);
|
||||
|
||||
const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => mj.getHouse(id));
|
||||
|
||||
if (res.ryuukyoku) {
|
||||
this.endKyoku(room, mj);
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'ryuukyoku', {
|
||||
});
|
||||
} else if (res.asking) {
|
||||
const answers: CallingAnswers = {
|
||||
pon: null,
|
||||
cii: null,
|
||||
kan: null,
|
||||
ron: {
|
||||
e: null,
|
||||
s: null,
|
||||
w: null,
|
||||
n: null,
|
||||
},
|
||||
};
|
||||
|
||||
// リーチ中はポン、チー、カンできない
|
||||
if (res.canPonHouse != null && mj.riichis[res.canPonHouse]) {
|
||||
answers.pon = false;
|
||||
}
|
||||
if (res.canCiiHouse != null && mj.riichis[res.canCiiHouse]) {
|
||||
answers.cii = false;
|
||||
}
|
||||
if (res.canKanHouse != null && mj.riichis[res.canKanHouse]) {
|
||||
answers.kan = false;
|
||||
}
|
||||
|
||||
if (aiHouses.includes(res.canPonHouse)) {
|
||||
// TODO: ちゃんと思考するようにする
|
||||
answers.pon = Math.random() < 0.25;
|
||||
}
|
||||
if (aiHouses.includes(res.canCiiHouse)) {
|
||||
// TODO: ちゃんと思考するようにする
|
||||
//answers.cii = Math.random() < 0.25;
|
||||
answers.cii = false;
|
||||
}
|
||||
if (aiHouses.includes(res.canKanHouse)) {
|
||||
// TODO: ちゃんと思考するようにする
|
||||
answers.kan = Math.random() < 0.25;
|
||||
}
|
||||
for (const h of res.canRonHouses) {
|
||||
if (aiHouses.includes(h)) {
|
||||
// TODO: ちゃんと思考するようにする
|
||||
}
|
||||
}
|
||||
|
||||
this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(answers));
|
||||
const waitingStartedAt = Date.now();
|
||||
const interval = setInterval(async () => {
|
||||
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
|
||||
if (current == null) throw new Error('arienai (gameCallingAsking)');
|
||||
const currentAnswers = JSON.parse(current) as CallingAnswers;
|
||||
const allAnswered = !(
|
||||
(res.canPonHouse != null && currentAnswers.pon == null) ||
|
||||
(res.canCiiHouse != null && currentAnswers.cii == null) ||
|
||||
(res.canKanHouse != null && currentAnswers.kan == null) ||
|
||||
(res.canRonHouses.includes('e') && currentAnswers.ron.e == null) ||
|
||||
(res.canRonHouses.includes('s') && currentAnswers.ron.s == null) ||
|
||||
(res.canRonHouses.includes('w') && currentAnswers.ron.w == null) ||
|
||||
(res.canRonHouses.includes('n') && currentAnswers.ron.n == null)
|
||||
);
|
||||
if (allAnswered || (Date.now() - waitingStartedAt > CALL_AND_RON_ASKING_TIMEOUT_MS)) {
|
||||
console.log(allAnswered ? 'ask all answerd' : 'ask timeout');
|
||||
await this.redisClient.del(`mahjong:gameCallingAsking:${room.id}`);
|
||||
clearInterval(interval);
|
||||
this.answer(room, mj, currentAnswers);
|
||||
return;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'dahai', { house: house, tile, riichi });
|
||||
} else {
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'dahaiAndTsumo', { dahaiHouse: house, dahaiTile: tile, tsumoTile: res.tsumoTile, riichi });
|
||||
|
||||
this.waitForTurn(room, res.next, mj);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async confirmNextKyoku(roomId: Room['id'], user: MiUser) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
|
||||
const confirmationRaw = await this.redisClient.get(`mahjong:gameNextKyokuConfirmation:${room.id}`);
|
||||
if (confirmationRaw == null) return;
|
||||
const confirmation = JSON.parse(confirmationRaw) as NextKyokuConfirmation;
|
||||
if (user.id === room.user1Id) confirmation.user1 = true;
|
||||
if (user.id === room.user2Id) confirmation.user2 = true;
|
||||
if (user.id === room.user3Id) confirmation.user3 = true;
|
||||
if (user.id === room.user4Id) confirmation.user4 = true;
|
||||
await this.redisClient.set(`mahjong:gameNextKyokuConfirmation:${room.id}`, JSON.stringify(confirmation));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: Mmj.TileId, riichi = false) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
const mj = new Mmj.MasterGameEngine(room.gameState);
|
||||
const myHouse = getHouseOfUserId(room, mj, user.id);
|
||||
|
||||
await this.clearTurnWaitingTimer(room.id);
|
||||
|
||||
await this.dahai(room, mj, myHouse, tile, riichi);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_ankan(roomId: MiMahjongGame['id'], user: MiUser, tile: Mmj.TileId) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
const mj = new Mmj.MasterGameEngine(room.gameState);
|
||||
const myHouse = getHouseOfUserId(room, mj, user.id);
|
||||
|
||||
await this.clearTurnWaitingTimer(room.id);
|
||||
|
||||
const res = mj.commit_ankan(myHouse, tile);
|
||||
room.gameState = mj.getState();
|
||||
await this.saveRoom(room);
|
||||
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'ankanned', { house: myHouse, tiles: res.tiles, rinsyan: res.rinsyan });
|
||||
|
||||
this.waitForTurn(room, myHouse, mj);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_kakan(roomId: MiMahjongGame['id'], user: MiUser, tile: Mmj.TileId) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
const mj = new Mmj.MasterGameEngine(room.gameState);
|
||||
const myHouse = getHouseOfUserId(room, mj, user.id);
|
||||
|
||||
await this.clearTurnWaitingTimer(room.id);
|
||||
|
||||
const res = mj.commit_kakan(myHouse, tile);
|
||||
room.gameState = mj.getState();
|
||||
await this.saveRoom(room);
|
||||
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'kakanned', { house: myHouse, tiles: res.tiles, rinsyan: res.rinsyan, from: res.from });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_tsumoHora(roomId: MiMahjongGame['id'], user: MiUser) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
const mj = new Mmj.MasterGameEngine(room.gameState);
|
||||
const myHouse = getHouseOfUserId(room, mj, user.id);
|
||||
|
||||
await this.clearTurnWaitingTimer(room.id);
|
||||
|
||||
const res = mj.commit_tsumoHora(myHouse);
|
||||
room.gameState = mj.getState();
|
||||
await this.saveRoom(room);
|
||||
|
||||
this.globalEventService.publishMahjongRoomStream(room.id, 'tsumoHora', { house: myHouse, handTiles: res.handTiles, tsumoTile: res.tsumoTile });
|
||||
|
||||
this.endKyoku(room, mj);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_ronHora(roomId: MiMahjongGame['id'], user: MiUser) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
const mj = new Mmj.MasterGameEngine(room.gameState);
|
||||
const myHouse = getHouseOfUserId(room, mj, user.id);
|
||||
|
||||
// TODO: 自分に回答する権利がある状態かバリデーション
|
||||
|
||||
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
|
||||
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
|
||||
if (current == null) throw new Error('no asking found');
|
||||
const currentAnswers = JSON.parse(current) as CallingAnswers;
|
||||
currentAnswers.ron[myHouse] = true;
|
||||
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_pon(roomId: MiMahjongGame['id'], user: MiUser) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
// TODO: 自分に回答する権利がある状態かバリデーション
|
||||
|
||||
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
|
||||
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
|
||||
if (current == null) throw new Error('no asking found');
|
||||
const currentAnswers = JSON.parse(current) as CallingAnswers;
|
||||
currentAnswers.pon = true;
|
||||
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_kan(roomId: MiMahjongGame['id'], user: MiUser) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
// TODO: 自分に回答する権利がある状態かバリデーション
|
||||
|
||||
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
|
||||
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
|
||||
if (current == null) throw new Error('no asking found');
|
||||
const currentAnswers = JSON.parse(current) as CallingAnswers;
|
||||
currentAnswers.kan = true;
|
||||
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_cii(roomId: MiMahjongGame['id'], user: MiUser, pattern: 'x__' | '_x_' | '__x') {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
// TODO: 自分に回答する権利がある状態かバリデーション
|
||||
|
||||
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
|
||||
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
|
||||
if (current == null) throw new Error('no asking found');
|
||||
const currentAnswers = JSON.parse(current) as CallingAnswers;
|
||||
currentAnswers.cii = pattern;
|
||||
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async commit_nop(roomId: MiMahjongGame['id'], user: MiUser) {
|
||||
const room = await this.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
if (room.gameState == null) return;
|
||||
|
||||
const mj = new Mmj.MasterGameEngine(room.gameState);
|
||||
const myHouse = getHouseOfUserId(room, mj, user.id);
|
||||
|
||||
// TODO: この辺の処理はアトミックに行いたいけどJSONサポートはRedis Stackが必要
|
||||
const current = await this.redisClient.get(`mahjong:gameCallingAsking:${room.id}`);
|
||||
if (current == null) throw new Error('no asking found');
|
||||
const currentAnswers = JSON.parse(current) as CallingAnswers;
|
||||
if (mj.askings.pon?.caller === myHouse) currentAnswers.pon = false;
|
||||
if (mj.askings.cii?.caller === myHouse) currentAnswers.cii = false;
|
||||
if (mj.askings.kan?.caller === myHouse) currentAnswers.kan = false;
|
||||
if (mj.askings.ron != null && mj.askings.ron.callers.includes(myHouse)) currentAnswers.ron[myHouse] = false;
|
||||
await this.redisClient.set(`mahjong:gameCallingAsking:${room.id}`, JSON.stringify(currentAnswers));
|
||||
}
|
||||
|
||||
/**
|
||||
* プレイヤーの行動(打牌、加槓、暗槓、ツモ和了)を待つ
|
||||
* 制限時間が過ぎたらツモ切り
|
||||
* NOTE: 時間切れチェックが行われたときにタイミングによっては次のwaitingが始まっている場合があることを考慮し、Setに一意のIDを格納する構造としている
|
||||
* @param room
|
||||
* @param house
|
||||
* @param mj
|
||||
*/
|
||||
@bindThis
|
||||
private async waitForTurn(room: Room, house: Mmj.House, mj: Mmj.MasterGameEngine) {
|
||||
const aiHouses = [[1, room.user1Ai], [2, room.user2Ai], [3, room.user3Ai], [4, room.user4Ai]].filter(([id, ai]) => ai).map(([id, ai]) => mj.getHouse(id));
|
||||
|
||||
if (mj.riichis[house]) {
|
||||
// リーチ時はアガリ牌でない限りツモ切り
|
||||
if (!Mmj.isAgarikei(mj.handTileTypes[house])) {
|
||||
setTimeout(() => {
|
||||
this.dahai(room, mj, house, mj.handTiles[house].at(-1));
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (aiHouses.includes(house)) {
|
||||
setTimeout(() => {
|
||||
this.dahai(room, mj, house, mj.handTiles[house].at(-1));
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
console.log('waitForTurn', house, id);
|
||||
this.redisClient.sadd(`mahjong:gameTurnWaiting:${room.id}`, id);
|
||||
const waitingStartedAt = Date.now();
|
||||
const interval = setInterval(async () => {
|
||||
const waiting = await this.redisClient.sismember(`mahjong:gameTurnWaiting:${room.id}`, id);
|
||||
if (waiting === 0) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - waitingStartedAt > TURN_TIMEOUT_MS) {
|
||||
await this.redisClient.srem(`mahjong:gameTurnWaiting:${room.id}`, id);
|
||||
console.log('turn timeout', house, id);
|
||||
clearInterval(interval);
|
||||
const handTiles = mj.handTiles[house];
|
||||
await this.dahai(room, mj, house, handTiles.at(-1));
|
||||
return;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* プレイヤーが行動(打牌、加槓、暗槓、ツモ和了)したら呼ぶ
|
||||
* @param roomId
|
||||
*/
|
||||
@bindThis
|
||||
private async clearTurnWaitingTimer(roomId: Room['id']) {
|
||||
await this.redisClient.del(`mahjong:gameTurnWaiting:${roomId}`);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public packState(room: Room, me: MiUser) {
|
||||
const mj = new Mmj.MasterGameEngine(room.gameState);
|
||||
const myIndex = room.user1Id === me.id ? 1 : room.user2Id === me.id ? 2 : room.user3Id === me.id ? 3 : 4;
|
||||
return mj.createPlayerState(myIndex);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packRoom(room: Room, me: MiUser) {
|
||||
if (room.gameState) {
|
||||
return {
|
||||
...room,
|
||||
gameState: this.packState(room, me),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...room,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ export const DI = {
|
|||
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
|
||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||
mahjongGamesRepository: Symbol('mahjongGamesRepository'),
|
||||
noteDraftsRepository: Symbol('noteDraftsRepository'),
|
||||
//#endregion
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const envOption = {
|
|||
verbose: false,
|
||||
withLogTime: false,
|
||||
quiet: false,
|
||||
forceGc: false,
|
||||
};
|
||||
|
||||
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage
|
|||
import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
|
||||
import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
|
||||
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
|
||||
import { packedMahjongRoomDetailedSchema } from '@/models/json-schema/mahjong-room.js';
|
||||
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
|
||||
import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js';
|
||||
|
||||
|
|
@ -147,6 +148,7 @@ export const refs = {
|
|||
ChatRoom: packedChatRoomSchema,
|
||||
ChatRoomInvitation: packedChatRoomInvitationSchema,
|
||||
ChatRoomMembership: packedChatRoomMembershipSchema,
|
||||
MahjongRoomDetailed: packedMahjongRoomDetailedSchema,
|
||||
};
|
||||
|
||||
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
@Entity('mahjong_game')
|
||||
export class MiMahjongGame {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
})
|
||||
public startedAt: Date | null;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
})
|
||||
public endedAt: Date | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public user1Id: MiUser['id'] | null;
|
||||
|
||||
@ManyToOne(type => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user1: MiUser | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public user2Id: MiUser['id'] | null;
|
||||
|
||||
@ManyToOne(type => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user2: MiUser | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public user3Id: MiUser['id'] | null;
|
||||
|
||||
@ManyToOne(type => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user3: MiUser | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public user4Id: MiUser['id'] | null;
|
||||
|
||||
@ManyToOne(type => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user4: MiUser | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isEnded: boolean;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public winnerId: MiUser['id'] | null;
|
||||
|
||||
// in sec
|
||||
@Column('smallint', {
|
||||
default: 90,
|
||||
})
|
||||
public timeLimitForEachTurn: number;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: [],
|
||||
})
|
||||
public logs: number[][];
|
||||
}
|
||||
|
|
@ -84,6 +84,7 @@ import {
|
|||
MiChatRoomMembership,
|
||||
MiChatRoomInvitation,
|
||||
MiChatApproval,
|
||||
MiMahjongGame,
|
||||
} from './_.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
|
@ -544,6 +545,12 @@ const $reversiGamesRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $mahjongGamesRepository: Provider = {
|
||||
provide: DI.mahjongGamesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiMahjongGame),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
|
|
@ -623,6 +630,7 @@ const $reversiGamesRepository: Provider = {
|
|||
$chatApprovalsRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$mahjongGamesRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
|
|
@ -701,6 +709,7 @@ const $reversiGamesRepository: Provider = {
|
|||
$chatApprovalsRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$mahjongGamesRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { MiGalleryLike } from '@/models/GalleryLike.js';
|
|||
import { MiGalleryPost } from '@/models/GalleryPost.js';
|
||||
import { MiHashtag } from '@/models/Hashtag.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import { MiMahjongGame } from '@/models/MahjongGame.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import { MiModerationLog } from '@/models/ModerationLog.js';
|
||||
import { MiMuting } from '@/models/Muting.js';
|
||||
|
|
@ -173,6 +174,7 @@ export {
|
|||
MiChatApproval,
|
||||
MiBubbleGameRecord,
|
||||
MiReversiGame,
|
||||
MiMahjongGame,
|
||||
};
|
||||
|
||||
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
|
||||
|
|
@ -253,3 +255,4 @@ 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>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const packedMahjongRoomDetailedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
startedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
endedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
isStarted: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isEnded: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user1Id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
user2Id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
format: 'id',
|
||||
},
|
||||
user3Id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
format: 'id',
|
||||
},
|
||||
user4Id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
format: 'id',
|
||||
},
|
||||
user1: {
|
||||
type: 'object',
|
||||
optional: false, nullable: true,
|
||||
ref: 'User',
|
||||
},
|
||||
user2: {
|
||||
type: 'object',
|
||||
optional: false, nullable: true,
|
||||
ref: 'User',
|
||||
},
|
||||
user3: {
|
||||
type: 'object',
|
||||
optional: false, nullable: true,
|
||||
ref: 'User',
|
||||
},
|
||||
user4: {
|
||||
type: 'object',
|
||||
optional: false, nullable: true,
|
||||
ref: 'User',
|
||||
},
|
||||
user1Ai: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user2Ai: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user3Ai: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user4Ai: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user1Ready: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user2Ready: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user3Ready: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user4Ready: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
timeLimitForEachTurn: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -85,6 +85,7 @@ import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
|
|||
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
|
||||
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||
import { MiMahjongGame } from '@/models/MahjongGame.js';
|
||||
import { MiChatApproval } from '@/models/ChatApproval.js';
|
||||
import { MiSystemAccount } from '@/models/SystemAccount.js';
|
||||
|
||||
|
|
@ -254,6 +255,7 @@ export const entities = [
|
|||
MiChatApproval,
|
||||
MiBubbleGameRecord,
|
||||
MiReversiGame,
|
||||
MiMahjongGame,
|
||||
...charts,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
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';
|
||||
|
|
@ -30,7 +31,6 @@ 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,6 +49,7 @@ 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({
|
||||
|
|
@ -92,6 +93,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
|
|||
ChatRoomChannel,
|
||||
ReversiChannel,
|
||||
ReversiGameChannel,
|
||||
MahjongRoomChannel,
|
||||
HomeTimelineChannel,
|
||||
HybridTimelineChannel,
|
||||
LocalTimelineChannel,
|
||||
|
|
|
|||
|
|
@ -301,6 +301,12 @@ export * as 'invite/create' from './endpoints/invite/create.js';
|
|||
export * as 'invite/delete' from './endpoints/invite/delete.js';
|
||||
export * as 'invite/limit' from './endpoints/invite/limit.js';
|
||||
export * as 'invite/list' from './endpoints/invite/list.js';
|
||||
export * as 'mahjong/cancel-match' from './endpoints/mahjong/cancel-match.js';
|
||||
export * as 'mahjong/create-room' from './endpoints/mahjong/create-room.js';
|
||||
export * as 'mahjong/games' from './endpoints/mahjong/games.js';
|
||||
export * as 'mahjong/join-room' from './endpoints/mahjong/join-room.js';
|
||||
export * as 'mahjong/show-room' from './endpoints/mahjong/show-room.js';
|
||||
export * as 'mahjong/verify' from './endpoints/mahjong/verify.js';
|
||||
export * as 'meta' from './endpoints/meta.js';
|
||||
export * as 'miauth/gen-token' from './endpoints/miauth/gen-token.js';
|
||||
export * as 'mute/create' from './endpoints/mute/create.js';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ReversiService } from '@/core/ReversiService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private reversiService: ReversiService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.userId) {
|
||||
await this.reversiService.matchSpecificUserCancel(me, ps.userId);
|
||||
return;
|
||||
} else {
|
||||
await this.reversiService.matchAnyUserCancel(me);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { MahjongService } from '@/core/MahjongService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
errors: {
|
||||
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'MahjongRoomDetailed',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private mahjongService: MahjongService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.mahjongService.createRoom(me);
|
||||
return await this.mahjongService.packRoom(room, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { ReversiGamesRepository } from '@/models/_.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: false,
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: { ref: 'ReversiGameLite' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
my: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.reversiGamesRepository)
|
||||
private reversiGamesRepository: ReversiGamesRepository,
|
||||
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
|
||||
.innerJoinAndSelect('game.user1', 'user1')
|
||||
.innerJoinAndSelect('game.user2', 'user2');
|
||||
|
||||
if (ps.my && me) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('game.user1Id = :userId', { userId: me.id })
|
||||
.orWhere('game.user2Id = :userId', { userId: me.id });
|
||||
}));
|
||||
} else {
|
||||
query.andWhere('game.isStarted = TRUE');
|
||||
}
|
||||
|
||||
const games = await query.take(ps.limit).getMany();
|
||||
|
||||
return await this.reversiGameEntityService.packLiteMany(games);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { MahjongService } from '@/core/MahjongService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
errors: {
|
||||
noSuchRoom: {
|
||||
message: 'No such room.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: '370e42b0-2a67-4306-9328-51c5f568f110',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'MahjongRoomDetailed',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['roomId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private mahjongService: MahjongService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.mahjongService.getRoom(ps.roomId);
|
||||
|
||||
if (room == null) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
await this.mahjongService.joinRoom(room.id, me);
|
||||
|
||||
return await this.mahjongService.packRoom(room, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { MahjongService } from '@/core/MahjongService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:account',
|
||||
|
||||
errors: {
|
||||
noSuchRoom: {
|
||||
message: 'No such room.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'd77df68f-06f3-492b-9078-e6f72f4acf23',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'MahjongRoomDetailed',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['roomId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private mahjongService: MahjongService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.mahjongService.getRoom(ps.roomId);
|
||||
|
||||
if (room == null) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
return await this.mahjongService.packRoom(room, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ReversiService } from '@/core/ReversiService.js';
|
||||
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
errors: {
|
||||
noSuchGame: {
|
||||
message: 'No such game.',
|
||||
code: 'NO_SUCH_GAME',
|
||||
id: '8fb05624-b525-43dd-90f7-511852bdfeee',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
desynced: { type: 'boolean' },
|
||||
game: {
|
||||
type: 'object',
|
||||
optional: true, nullable: true,
|
||||
ref: 'ReversiGameDetailed',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
gameId: { type: 'string', format: 'misskey:id' },
|
||||
crc32: { type: 'string' },
|
||||
},
|
||||
required: ['gameId', 'crc32'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private reversiService: ReversiService,
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const game = await this.reversiService.checkCrc(ps.gameId, ps.crc32);
|
||||
if (game) {
|
||||
return {
|
||||
desynced: true,
|
||||
game: await this.reversiGameEntityService.packDetail(game),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
desynced: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
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';
|
||||
|
|
@ -22,8 +23,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 {
|
||||
|
|
@ -52,6 +53,7 @@ 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}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
/*
|
||||
* 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,6 +78,15 @@ 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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
|
@ -58,6 +58,7 @@
|
|||
"mediabunny": "1.27.2",
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-mahjong": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
||||
</MkA>
|
||||
</div>
|
||||
<div class="_panel">
|
||||
<MkA to="/mahjong">
|
||||
<img src="/client-assets/mahjong/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
||||
</MkA>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithHeader>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[$style.root]">
|
||||
<div
|
||||
v-for="tile in Mmj.sortTiles(separateLast ? tiles.slice(0, tiles.length - 1) : tiles)"
|
||||
:class="[$style.tile, { [$style.tileNonSelectable]: selectableTiles != null && !selectableTiles.includes(mj$type(tile)), [$style.tileDora]: doras.includes(mj$type(tile)) }]"
|
||||
@click="chooseTile(tile, $event)"
|
||||
>
|
||||
<div :class="$style.tileInner">
|
||||
<div :class="$style.tileBg1"></div>
|
||||
<div :class="$style.tileBg2"></div>
|
||||
<div :class="$style.tileBg3"></div>
|
||||
<img :src="`/client-assets/mahjong/tiles/${mj$(tile).red ? mj$type(tile) + 'r' : mj$type(tile)}.png`" :class="$style.tileFg1"/>
|
||||
<div :class="$style.tileFg2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="separateLast"
|
||||
style="display: inline-block; margin-left: 5px;"
|
||||
:class="[$style.tile, { [$style.tileNonSelectable]: selectableTiles != null && !selectableTiles.includes(mj$type(tiles.at(-1))), [$style.tileDora]: doras.includes(mj$type(tiles.at(-1))) }]"
|
||||
@click="chooseTile(tiles.at(-1), $event)"
|
||||
>
|
||||
<div :class="$style.tileInner">
|
||||
<div :class="$style.tileBg1"></div>
|
||||
<div :class="$style.tileBg2"></div>
|
||||
<div :class="$style.tileBg3"></div>
|
||||
<img :src="`/client-assets/mahjong/tiles/${mj$(tiles.at(-1)).red ? mj$type(tiles.at(-1)) + 'r' : mj$type(tiles.at(-1))}.png`" :class="$style.tileFg1"/>
|
||||
<div :class="$style.tileFg2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
|
||||
//#region syntax suger
|
||||
function mj$(tid: Mmj.TileId): Mmj.TileInstance {
|
||||
return Mmj.findTileByIdOrFail(tid);
|
||||
}
|
||||
|
||||
function mj$type(tid: Mmj.TileId): Mmj.TileType {
|
||||
return mj$(tid).t;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const props = defineProps<{
|
||||
tiles: Mmj.TileId[];
|
||||
doras: Mmj.TileType[];
|
||||
selectableTiles: Mmj.TileType[] | null;
|
||||
separateLast: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'choose', tile: Mmj.TileId): void;
|
||||
}>();
|
||||
|
||||
function chooseTile(tile: Mmj.TileId, event: MouseEvent) {
|
||||
if (props.selectableTiles != null && !props.selectableTiles.includes(mj$type(tile))) return;
|
||||
emit('choose', tile);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@keyframes shine {
|
||||
0% { translate: -20%; }
|
||||
100% { translate: -70%; }
|
||||
}
|
||||
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
position: relative;
|
||||
width: 35px;
|
||||
aspect-ratio: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tileInner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: clip;
|
||||
border-radius: 4px;
|
||||
transition: translate 0.1s ease;
|
||||
}
|
||||
.tile:hover > .tileInner {
|
||||
translate: 0 -10px;
|
||||
}
|
||||
.tileNonSelectable {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tileDora > .tileInner {
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
rotate: -45deg;
|
||||
translate: -30px;
|
||||
background: #ffffffee;
|
||||
animation: shine 2s infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tileBg1 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: #E38A45;
|
||||
}
|
||||
.tileBg2 {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 95%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: #DFDEDD;
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 78%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 6%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: linear-gradient(0deg, #fff 0%, #fff0 100%);
|
||||
}
|
||||
}
|
||||
.tileBg3 {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 75%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
}
|
||||
.tileFg1 {
|
||||
position: absolute;
|
||||
bottom: 5%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 65%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.tileFg2 {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 1px #000 inset;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="huro.type === 'ankan'" :class="[$style.root]">
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[0])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[1])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[2])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[3])" :variation="variation" :doras="doras"/>
|
||||
</div>
|
||||
<div v-else-if="huro.type === 'minkan'" :class="[$style.root]">
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[0])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[1])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[2])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[3])" :variation="variation" :doras="doras"/>
|
||||
</div>
|
||||
<div v-else-if="huro.type === 'cii'" :class="[$style.root]">
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[0])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[1])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[2])" :variation="variation" :doras="doras"/>
|
||||
</div>
|
||||
<div v-else :class="[$style.root]">
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[0])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[1])" :variation="variation" :doras="doras"/>
|
||||
<XTile :tile="Mmj.findTileByIdOrFail(huro.tiles[2])" :variation="variation" :doras="doras"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
import XTile from './tile.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
huro: Mmj.Huro;
|
||||
variation: string;
|
||||
doras: Mmj.TileType[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkSpacer :contentMax="600">
|
||||
<div class="_gaps">
|
||||
<div>
|
||||
<img src="/client-assets/mahjong/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
|
||||
</div>
|
||||
|
||||
<div class="_panel _gaps" style="padding: 16px;">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton primary gradate rounded @click="joinRoom">{{ i18n.ts._mahjong.joinRoom }}</MkButton>
|
||||
<MkButton primary gradate rounded @click="createRoom">{{ i18n.ts._mahjong.createRoom }}</MkButton>
|
||||
</div>
|
||||
<div style="font-size: 90%; opacity: 0.7; text-align: center;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { 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>
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<MkSpacer :contentMax="600">
|
||||
<div class="_gaps">
|
||||
<div class="_panel">
|
||||
<MkAvatar v-if="room.user1" :user="room.user1" :class="$style.userAvatar"/>
|
||||
<div v-else-if="room.user1Ai">AI</div>
|
||||
<div v-if="room.user1Ready">OK</div>
|
||||
</div>
|
||||
<div class="_panel">
|
||||
<MkAvatar v-if="room.user2" :user="room.user2" :class="$style.userAvatar"/>
|
||||
<div v-else-if="room.user2Ai">AI</div>
|
||||
<div v-if="room.user2Ready">OK</div>
|
||||
</div>
|
||||
<div class="_panel">
|
||||
<MkAvatar v-if="room.user3" :user="room.user3" :class="$style.userAvatar"/>
|
||||
<div v-else-if="room.user3Ai">AI</div>
|
||||
<div v-if="room.user3Ready">OK</div>
|
||||
</div>
|
||||
<div class="_panel">
|
||||
<MkAvatar v-if="room.user4" :user="room.user4" :class="$style.userAvatar"/>
|
||||
<div v-else-if="room.user4Ai">AI</div>
|
||||
<div v-if="room.user4Ready">OK</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<MkButton rounded primary @click="addCpu">{{ i18n.ts._mahjong.addCpu }}</MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
|
||||
<div style="text-align: center;" class="_gaps_s">
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded danger @click="leave">{{ i18n.ts._mahjong.leave }}</MkButton>
|
||||
<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._mahjong.ready }}</MkButton>
|
||||
<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._mahjong.cancelReady }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
import 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>
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="room == null || (!room.isEnded && connection == null)"><MkLoading/></div>
|
||||
<RoomSetting v-else-if="!room.isStarted" :room="room" :connection="connection!"/>
|
||||
<RoomGame v-else :room="room" :connection="connection"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import RoomSetting from './room.setting.vue';
|
||||
import RoomGame from './room.game.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
roomId: string;
|
||||
}>();
|
||||
|
||||
const room = shallowRef<Misskey.entities.MahjongRoomDetailed | null>(null);
|
||||
const connection = shallowRef<Misskey.ChannelConnection<Misskey.Channels['mahjongRoom']> | null>(null);
|
||||
const shareWhenStart = ref(false);
|
||||
|
||||
watch(() => props.roomId, () => {
|
||||
fetchGame();
|
||||
});
|
||||
|
||||
function start(_room: Misskey.entities.MahjongRoomDetailed) {
|
||||
if (room.value?.isStarted) return;
|
||||
|
||||
room.value = _room;
|
||||
}
|
||||
|
||||
async function fetchGame() {
|
||||
const _room = await misskeyApi('mahjong/show-room', {
|
||||
roomId: props.roomId,
|
||||
});
|
||||
|
||||
room.value = _room;
|
||||
shareWhenStart.value = false;
|
||||
|
||||
if (connection.value) {
|
||||
connection.value.dispose();
|
||||
}
|
||||
if (!room.value.isEnded) {
|
||||
connection.value = useStream().useChannel('mahjongRoom', {
|
||||
roomId: room.value.id,
|
||||
});
|
||||
connection.value.on('started', x => {
|
||||
start(x.room);
|
||||
});
|
||||
connection.value.on('canceled', x => {
|
||||
connection.value?.dispose();
|
||||
|
||||
if (x.userId !== $i.id) {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
text: i18n.ts._mahjong.roomCanceled,
|
||||
});
|
||||
router.push('/mahjong');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 通信を取りこぼした場合の救済
|
||||
useInterval(async () => {
|
||||
if (room.value == null) return;
|
||||
if (room.value.isStarted) return;
|
||||
|
||||
const _room = await misskeyApi('mahjong/show-room', {
|
||||
roomId: props.roomId,
|
||||
});
|
||||
|
||||
if (_room.isStarted) {
|
||||
start(_room);
|
||||
} else {
|
||||
room.value = _room;
|
||||
}
|
||||
}, 1000 * 10, {
|
||||
immediate: false,
|
||||
afterMounted: true,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchGame();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (connection.value) {
|
||||
connection.value.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
definePage(computed(() => ({
|
||||
title: i18n.ts._mahjong.mahjong,
|
||||
icon: 'ti ti-device-roompad',
|
||||
})));
|
||||
</script>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[$style.root, { [$style.h]: ['3', '4', '5'].includes(variation), [$style.v]: ['1', '2'].includes(variation), [$style.isDora]: isDora }]">
|
||||
<img :src="`/client-assets/mahjong/putted-tile-${variation}.png`" :class="$style.bg"/>
|
||||
<img :src="`/client-assets/mahjong/tiles/${tile.red ? tile.t + 'r' : tile.t}.png`" :class="$style.fg"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Mmj from 'misskey-mahjong';
|
||||
|
||||
const props = defineProps<{
|
||||
tile: Mmj.TileInstance;
|
||||
variation: string;
|
||||
doras: Mmj.TileType[];
|
||||
}>();
|
||||
|
||||
const isDora = computed(() => props.doras.includes(props.tile.t));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@keyframes shine {
|
||||
0% { translate: -30px; }
|
||||
100% { translate: -130px; }
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: -17px;
|
||||
}
|
||||
.h {
|
||||
margin: -14px -19px -5px;
|
||||
}
|
||||
.v {
|
||||
margin: -14px -18px -11px;
|
||||
}
|
||||
.bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.fg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
width: 53%;
|
||||
height: 53%;
|
||||
object-fit: contain;
|
||||
}
|
||||
/*
|
||||
.isDora {
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
rotate: -45deg;
|
||||
translate: -30px;
|
||||
background: #ffffffee;
|
||||
animation: shine 2s infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
}*/
|
||||
</style>
|
||||
|
|
@ -590,6 +590,14 @@ export const ROUTE_DEF = [{
|
|||
path: '/reversi/g/:gameId',
|
||||
component: page(() => import('@/pages/reversi/game.vue')),
|
||||
loginRequired: false,
|
||||
}, {
|
||||
path: '/mahjong',
|
||||
component: page(() => import('@/pages/mahjong/index.vue')),
|
||||
loginRequired: false,
|
||||
}, {
|
||||
path: '/mahjong/g/:roomId',
|
||||
component: page(() => import('@/pages/mahjong/room.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
path: '/qr',
|
||||
component: page(() => import('@/pages/qr.vue')),
|
||||
|
|
|
|||