Merge remote-tracking branch 'misskey-dev/develop' into io

This commit is contained in:
まっちゃとーにゅ 2024-01-24 18:51:48 +09:00
commit c668dc782a
No known key found for this signature in database
GPG Key ID: 143DE582A97FE052
26 changed files with 720 additions and 443 deletions

20
locales/index.d.ts vendored
View File

@ -9795,6 +9795,26 @@ export interface Locale extends ILocale {
* *
*/ */
"gameCanceled": string; "gameCanceled": string;
/**
* 稿
*/
"shareToTlTheGameWhenStart": string;
/**
* #MisskeyReversi
*/
"iStartedAGame": string;
/**
*
*/
"opponentHasSettingsChanged": string;
/**
* ()
*/
"allowIrregularRules": string;
/**
*
*/
"disallowIrregularRules": string;
}; };
"_offlineScreen": { "_offlineScreen": {
/** /**

View File

@ -2611,6 +2611,11 @@ _reversi:
freeMatch: "フリーマッチ" freeMatch: "フリーマッチ"
lookingForPlayer: "対戦相手を探しています" lookingForPlayer: "対戦相手を探しています"
gameCanceled: "対局がキャンセルされました" gameCanceled: "対局がキャンセルされました"
shareToTlTheGameWhenStart: "開始時に対局をタイムラインに投稿"
iStartedAGame: "対局を開始しました! #MisskeyReversi"
opponentHasSettingsChanged: "相手が設定を変更しました"
allowIrregularRules: "変則許可 (完全フリー)"
disallowIrregularRules: "変則なし"
_offlineScreen: _offlineScreen:
title: "オフライン - サーバーに接続できません" title: "オフライン - サーバーに接続できません"

View File

@ -380,8 +380,11 @@ hcaptcha: "hCaptchaキャプチャ"
enableHcaptcha: "hCaptchaキャプチャをつけとく" enableHcaptcha: "hCaptchaキャプチャをつけとく"
hcaptchaSiteKey: "サイトキー" hcaptchaSiteKey: "サイトキー"
hcaptchaSecretKey: "シークレットキー" hcaptchaSecretKey: "シークレットキー"
mcaptcha: "mCaptcha"
enableMcaptcha: "hCaptchaキャプチャをつけとく"
mcaptchaSiteKey: "サイトキー" mcaptchaSiteKey: "サイトキー"
mcaptchaSecretKey: "シークレットキー" mcaptchaSecretKey: "シークレットキー"
mcaptchaInstanceUrl: "mCaptchaのインスタンスのURL"
recaptcha: "reCAPTCHA" recaptcha: "reCAPTCHA"
enableRecaptcha: "reCAPTCHAリキャプチャを有効にする" enableRecaptcha: "reCAPTCHAリキャプチャを有効にする"
recaptchaSiteKey: "サイトキー" recaptchaSiteKey: "サイトキー"
@ -629,6 +632,7 @@ medium: "中"
small: "小" small: "小"
generateAccessToken: "アクセストークンの発行" generateAccessToken: "アクセストークンの発行"
permission: "権限" permission: "権限"
adminPermission: "管理者権限"
enableAll: "全部使えるようにする" enableAll: "全部使えるようにする"
disableAll: "全部使えへんようにする" disableAll: "全部使えへんようにする"
tokenRequested: "アカウントへのアクセス許してやったらどうや" tokenRequested: "アカウントへのアクセス許してやったらどうや"
@ -1055,6 +1059,8 @@ limitWidthOfReaction: "ツッコミの最大横幅を制限して、ちっさく
noteIdOrUrl: "ートIDかURL" noteIdOrUrl: "ートIDかURL"
video: "動画" video: "動画"
videos: "動画" videos: "動画"
audio: "音声"
audioFiles: "音声"
dataSaver: "データケチケチ" dataSaver: "データケチケチ"
accountMigration: "アカウントのお引っ越し" accountMigration: "アカウントのお引っ越し"
accountMoved: "このユーザーはさらのアカウントに引っ越したで:" accountMoved: "このユーザーはさらのアカウントに引っ越したで:"
@ -1187,7 +1193,25 @@ seasonalScreenEffect: "季節にあった画面の動き"
decorate: "デコる" decorate: "デコる"
addMfmFunction: "装飾つける" addMfmFunction: "装飾つける"
enableQuickAddMfmFunction: "ややこしいMFMのピッカーを出す" enableQuickAddMfmFunction: "ややこしいMFMのピッカーを出す"
bubbleGame: "バブルゲーム"
sfx: "効果音"
soundWillBePlayed: "サウンドが再生されるで"
showReplay: "リプレイ見る"
replay: "リプレイ"
replaying: "リプレイ中"
ranking: "ランキング"
lastNDays: "直近{n}日" lastNDays: "直近{n}日"
backToTitle: "タイトルへ"
hemisphere: "住んでる地域"
withSensitive: "センシティブなファイルを含むノートを表示"
userSaysSomethingSensitive: "{name}のセンシティブなファイルを含む投稿"
enableHorizontalSwipe: "スワイプしてタブを切り替える"
_bubbleGame:
howToPlay: "遊び方"
_howToPlay:
section1: "位置を調整してハコにモノを落とすで。"
section2: "同じもんがくっついたら別のやつになって、スコアがもらえるで。"
section3: "モノがハコからあふれたらゲームオーバーや。ハコからあふれんようにしながらモノを融合させてハイスコアを目指しいや!"
_announcement: _announcement:
forExistingUsers: "もうおるユーザーのみ" forExistingUsers: "もうおるユーザーのみ"
forExistingUsersDescription: "オンにしたらこのお知らせができた時点でおる人らにだけお知らせが行くで。切ったらこの知らせが行ったあとにアカウント作った人にもちゃんとお知らせが行くで。" forExistingUsersDescription: "オンにしたらこのお知らせができた時点でおる人らにだけお知らせが行くで。切ったらこの知らせが行ったあとにアカウント作った人にもちゃんとお知らせが行くで。"
@ -1558,6 +1582,13 @@ _achievements:
_tutorialCompleted: _tutorialCompleted:
title: "Misskeyひよっこ講座 修了証" title: "Misskeyひよっこ講座 修了証"
description: "チュートリアル全部やった" description: "チュートリアル全部やった"
_bubbleGameExplodingHead:
title: "🤯"
description: "バブルゲームで最も大きいモノを出した"
_bubbleGameDoubleExplodingHead:
title: "ダブル🤯"
description: "バブルゲームで最も大きいモを2つ同時に出した"
flavor: "これくらいの おべんとばこに 🤯 🤯 ちょっとつめて"
_role: _role:
new: "ロールの作成" new: "ロールの作成"
edit: "ロールの編集" edit: "ロールの編集"
@ -2410,6 +2441,51 @@ _dataSaver:
_code: _code:
title: "コードハイライト" title: "コードハイライト"
description: "MFMとかでコードハイライト記法が使われてるとき、タップするまで読み込まれへんくなるで。コードハイライトではハイライトする言語ごとにその決めてるファイルを読む必要はあんねんな。けどな、それは自動で読み込まれなくなるから、通信量を少なくできることができるねん。" description: "MFMとかでコードハイライト記法が使われてるとき、タップするまで読み込まれへんくなるで。コードハイライトではハイライトする言語ごとにその決めてるファイルを読む必要はあんねんな。けどな、それは自動で読み込まれなくなるから、通信量を少なくできることができるねん。"
_hemisphere:
N: "北半球"
S: "南半球"
caption: "一部のクライアント設定で、季節を判定するのに使用するで。"
_reversi: _reversi:
reversi: "リバーシ"
gameSettings: "対局の設定"
chooseBoard: "ボードを選択"
blackOrWhite: "先行/後攻"
blackIs: "{name}が黒(先行)"
rules: "ルール"
thisGameIsStartedSoon: "対局、そろそろ開始されるで。"
waitingForOther: "相手の準備が完了するのを待ってんで。"
waitingForMe: "あんさんの準備が完了すんのを待ってんで"
waitingBoth: "準備してなー"
ready: "準備完了"
cancelReady: "準備を再開"
opponentTurn: "相手のターンやで"
myTurn: "あんさんのターンや"
turnOf: "{name}のターンやで"
pastTurnOf: "{name}のターン"
surrender: "投了"
surrendered: "投了により"
timeout: "時間切れ"
drawn: "引き分け"
won: "{name}の勝ち"
black: "黒"
white: "白"
total: "合計" total: "合計"
turnCount: "{count}ターン目"
myGames: "自分の対局"
allGames: "みんなの対局"
ended: "終了"
playing: "対局中"
isLlotheo: "石の少ない方が勝ち(ロセオ)"
loopedMap: "ループマップ"
canPutEverywhere: "どこでも置けるモード"
timeLimitForEachTurn: "1ターンの時間制限"
freeMatch: "フリーマッチ"
lookingForPlayer: "対戦相手を探してるで"
gameCanceled: "対局がキャンセルされたわ"
shareToTlTheGameWhenStart: "初めの時に対局をタイムラインに投稿するで"
iStartedAGame: "対局し始めたで! #MisskeyReversi"
opponentHasSettingsChanged: "相手が設定変えたで"
_offlineScreen:
title: "オフライン - サーバーに接続できひんで"
header: "サーバーに接続できへんわ"

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Reversi61706081514499 {
name = 'Reversi61706081514499'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "reversi_game" ADD "noIrregularRules" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "noIrregularRules"`);
}
}

View File

@ -81,13 +81,13 @@
"@fastify/view": "8.2.0", "@fastify/view": "8.2.0",
"@misskey-dev/sharp-read-bmp": "^1.1.1", "@misskey-dev/sharp-read-bmp": "^1.1.1",
"@misskey-dev/summaly": "^5.0.3", "@misskey-dev/summaly": "^5.0.3",
"@nestjs/common": "10.2.10", "@nestjs/common": "10.3.1",
"@nestjs/core": "10.2.10", "@nestjs/core": "10.3.1",
"@nestjs/testing": "10.2.10", "@nestjs/testing": "10.3.1",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "9.0.0", "@simplewebauthn/server": "9.0.0",
"@sinonjs/fake-timers": "11.2.2", "@sinonjs/fake-timers": "11.2.2",
"@smithy/node-http-handler": "2.1.10", "@smithy/node-http-handler": "2.3.1",
"@swc/cli": "0.1.65", "@swc/cli": "0.1.65",
"@swc/core": "1.3.105", "@swc/core": "1.3.105",
"@twemoji/parser": "15.0.0", "@twemoji/parser": "15.0.0",
@ -98,7 +98,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bullmq": "5.1.4", "bullmq": "5.1.5",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.1", "cbor": "9.0.1",
"chalk": "5.3.0", "chalk": "5.3.0",
@ -186,7 +186,7 @@
"devDependencies": { "devDependencies": {
"@jest/globals": "29.7.0", "@jest/globals": "29.7.0",
"@misskey-dev/eslint-plugin": "1.0.0", "@misskey-dev/eslint-plugin": "1.0.0",
"@nestjs/platform-express": "10.3.0", "@nestjs/platform-express": "10.3.1",
"@simplewebauthn/types": "9.0.0", "@simplewebauthn/types": "9.0.0",
"@swc/jest": "0.2.31", "@swc/jest": "0.2.31",
"@types/accepts": "1.3.7", "@types/accepts": "1.3.7",
@ -204,7 +204,7 @@
"@types/jsrsasign": "10.5.12", "@types/jsrsasign": "10.5.12",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/ms": "0.7.34", "@types/ms": "0.7.34",
"@types/node": "20.11.5", "@types/node": "20.11.6",
"@types/nodemailer": "6.4.14", "@types/nodemailer": "6.4.14",
"@types/oauth": "0.9.4", "@types/oauth": "0.9.4",
"@types/oauth2orize": "1.11.3", "@types/oauth2orize": "1.11.3",

View File

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import * as Reversi from 'misskey-reversi'; import * as Reversi from 'misskey-reversi';
import { LessThan, MoreThan } from 'typeorm';
import type { import type {
MiReversiGame, MiReversiGame,
ReversiGamesRepository, ReversiGamesRepository,
@ -23,7 +24,7 @@ import { Serialized } from '@/types.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js'; import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
@Injectable() @Injectable()
export class ReversiService implements OnModuleInit { export class ReversiService implements OnModuleInit {
@ -84,44 +85,82 @@ export class ReversiService implements OnModuleInit {
map: game.map, map: game.map,
bw: game.bw, bw: game.bw,
crc32: game.crc32, crc32: game.crc32,
noIrregularRules: game.noIrregularRules,
} satisfies Partial<MiReversiGame>; } satisfies Partial<MiReversiGame>;
} }
@bindThis @bindThis
public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> { public async matchSpecificUser(me: MiUser, targetUser: MiUser, multiple = false): Promise<MiReversiGame | null> {
if (targetUser.id === me.id) { if (targetUser.id === me.id) {
throw new Error('You cannot match yourself.'); throw new Error('You cannot match yourself.');
} }
if (!multiple) {
// 既にマッチしている対局が無いか探す(3分以内)
const games = await this.reversiGamesRepository.find({
where: [
{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: me.id, user2Id: targetUser.id, isStarted: false },
{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: targetUser.id, user2Id: me.id, isStarted: false },
],
relations: ['user1', 'user2'],
order: { id: 'DESC' },
});
if (games.length > 0) {
return games[0];
}
}
//#region 相手から既に招待されてないか確認
const invitations = await this.redisClient.zrange( const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${me.id}`, `reversi:matchSpecific:${me.id}`,
Date.now() - MATCHING_TIMEOUT_MS, Date.now() - INVITATION_TIMEOUT_MS,
'+inf', '+inf',
'BYSCORE'); 'BYSCORE');
if (invitations.includes(targetUser.id)) { if (invitations.includes(targetUser.id)) {
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id); await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
const game = await this.matched(targetUser.id, me.id); const game = await this.matched(targetUser.id, me.id, {
noIrregularRules: false,
return game;
} else {
this.redisClient.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
user: await this.userEntityService.pack(me, targetUser),
}); });
return null; return game;
} }
//#endregion
const redisPipeline = this.redisClient.pipeline();
redisPipeline.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
redisPipeline.expire(`reversi:matchSpecific:${targetUser.id}`, 120, 'NX');
await redisPipeline.exec();
this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
user: await this.userEntityService.pack(me, targetUser),
});
return null;
} }
@bindThis @bindThis
public async matchAnyUser(me: MiUser): Promise<MiReversiGame | null> { public async matchAnyUser(me: MiUser, options: { noIrregularRules: boolean }, multiple = false): Promise<MiReversiGame | null> {
if (!multiple) {
// 既にマッチしている対局が無いか探す(3分以内)
const games = await this.reversiGamesRepository.find({
where: [
{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: me.id, isStarted: false },
{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user2Id: me.id, isStarted: false },
],
relations: ['user1', 'user2'],
order: { id: 'DESC' },
});
if (games.length > 0) {
return games[0];
}
}
//#region まず自分宛ての招待を探す //#region まず自分宛ての招待を探す
const invitations = await this.redisClient.zrange( const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${me.id}`, `reversi:matchSpecific:${me.id}`,
Date.now() - MATCHING_TIMEOUT_MS, Date.now() - INVITATION_TIMEOUT_MS,
'+inf', '+inf',
'BYSCORE'); 'BYSCORE');
@ -129,7 +168,9 @@ export class ReversiService implements OnModuleInit {
const invitorId = invitations[Math.floor(Math.random() * invitations.length)]; const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId); await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
const game = await this.matched(invitorId, me.id); const game = await this.matched(invitorId, me.id, {
noIrregularRules: false,
});
return game; return game;
} }
@ -137,23 +178,35 @@ export class ReversiService implements OnModuleInit {
const matchings = await this.redisClient.zrange( const matchings = await this.redisClient.zrange(
'reversi:matchAny', 'reversi:matchAny',
Date.now() - MATCHING_TIMEOUT_MS, 0,
'+inf', 2, // 自分自身のIDが入っている場合もあるので2つ取得
'BYSCORE'); 'REV');
const userIds = matchings.filter(id => id !== me.id); const items = matchings.filter(id => !id.startsWith(me.id));
if (userIds.length > 0) { if (items.length > 0) {
// pick random const [matchedUserId, option] = items[0].split(':');
const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)];
await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId); await this.redisClient.zrem('reversi:matchAny',
me.id,
matchedUserId,
me.id + ':noIrregularRules',
matchedUserId + ':noIrregularRules');
const game = await this.matched(matchedUserId, me.id); const game = await this.matched(matchedUserId, me.id, {
noIrregularRules: options.noIrregularRules || option === 'noIrregularRules',
});
return game; return game;
} else { } else {
await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id); const redisPipeline = this.redisClient.pipeline();
if (options.noIrregularRules) {
redisPipeline.zadd('reversi:matchAny', Date.now(), me.id + ':noIrregularRules');
} else {
redisPipeline.zadd('reversi:matchAny', Date.now(), me.id);
}
redisPipeline.expire('reversi:matchAny', 15, 'NX');
await redisPipeline.exec();
return null; return null;
} }
} }
@ -165,7 +218,15 @@ export class ReversiService implements OnModuleInit {
@bindThis @bindThis
public async matchAnyUserCancel(user: MiUser) { public async matchAnyUserCancel(user: MiUser) {
await this.redisClient.zrem('reversi:matchAny', user.id); await this.redisClient.zrem('reversi:matchAny', user.id, user.id + ':noIrregularRules');
}
@bindThis
public async cleanOutdatedGames() {
await this.reversiGamesRepository.delete({
id: LessThan(this.idService.gen(Date.now() - 1000 * 60 * 10)),
isStarted: false,
});
} }
@bindThis @bindThis
@ -219,7 +280,7 @@ export class ReversiService implements OnModuleInit {
} }
@bindThis @bindThis
private async matched(parentId: MiUser['id'], childId: MiUser['id']): Promise<MiReversiGame> { private async matched(parentId: MiUser['id'], childId: MiUser['id'], options: { noIrregularRules: boolean; }): Promise<MiReversiGame> {
const game = await this.reversiGamesRepository.insert({ const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(), id: this.idService.gen(),
user1Id: parentId, user1Id: parentId,
@ -232,6 +293,7 @@ export class ReversiService implements OnModuleInit {
map: Reversi.maps.eighteight.data, map: Reversi.maps.eighteight.data,
bw: 'random', bw: 'random',
isLlotheo: false, isLlotheo: false,
noIrregularRules: options.noIrregularRules,
}).then(x => this.reversiGamesRepository.findOneOrFail({ }).then(x => this.reversiGamesRepository.findOneOrFail({
where: { id: x.identifiers[0].id }, where: { id: x.identifiers[0].id },
relations: ['user1', 'user2'], relations: ['user1', 'user2'],
@ -333,7 +395,7 @@ export class ReversiService implements OnModuleInit {
public async getInvitations(user: MiUser): Promise<MiUser['id'][]> { public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
const invitations = await this.redisClient.zrange( const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${user.id}`, `reversi:matchSpecific:${user.id}`,
Date.now() - MATCHING_TIMEOUT_MS, Date.now() - INVITATION_TIMEOUT_MS,
'+inf', '+inf',
'BYSCORE'); 'BYSCORE');
return invitations; return invitations;

View File

@ -60,6 +60,7 @@ export class ReversiGameEntityService {
canPutEverywhere: game.canPutEverywhere, canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard, loopedBoard: game.loopedBoard,
timeLimitForEachTurn: game.timeLimitForEachTurn, timeLimitForEachTurn: game.timeLimitForEachTurn,
noIrregularRules: game.noIrregularRules,
logs: game.logs, logs: game.logs,
map: game.map, map: game.map,
}); });
@ -108,6 +109,7 @@ export class ReversiGameEntityService {
canPutEverywhere: game.canPutEverywhere, canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard, loopedBoard: game.loopedBoard,
timeLimitForEachTurn: game.timeLimitForEachTurn, timeLimitForEachTurn: game.timeLimitForEachTurn,
noIrregularRules: game.noIrregularRules,
}); });
} }

View File

@ -113,6 +113,11 @@ export class MiReversiGame {
}) })
public bw: string; public bw: string;
@Column('boolean', {
default: false,
})
public noIrregularRules: boolean;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
}) })

View File

@ -82,6 +82,10 @@ export const packedReversiGameLiteSchema = {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
}, },
noIrregularRules: {
type: 'boolean',
optional: false, nullable: false,
},
isLlotheo: { isLlotheo: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@ -196,6 +200,10 @@ export const packedReversiGameDetailedSchema = {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
}, },
noIrregularRules: {
type: 'boolean',
optional: false, nullable: false,
},
isLlotheo: { isLlotheo: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -11,6 +11,7 @@ import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { ReversiService } from '@/core/ReversiService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
@ -32,6 +33,7 @@ export class CleanProcessorService {
private roleAssignmentsRepository: RoleAssignmentsRepository, private roleAssignmentsRepository: RoleAssignmentsRepository,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private reversiService: ReversiService,
private idService: IdService, private idService: IdService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('clean'); this.logger = this.queueLoggerService.logger.createSubLogger('clean');
@ -65,6 +67,8 @@ export class CleanProcessorService {
}); });
} }
this.reversiService.cleanOutdatedGames();
this.logger.succ('Cleaned.'); this.logger.succ('Cleaned.');
} }
} }

View File

@ -43,7 +43,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
.andWhere('game.isStarted = TRUE')
.innerJoinAndSelect('game.user1', 'user1') .innerJoinAndSelect('game.user1', 'user1')
.innerJoinAndSelect('game.user2', 'user2'); .innerJoinAndSelect('game.user2', 'user2');
@ -53,6 +52,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where('game.user1Id = :userId', { userId: me.id }) .where('game.user1Id = :userId', { userId: me.id })
.orWhere('game.user2Id = :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(); const games = await query.take(ps.limit).getMany();

View File

@ -37,6 +37,8 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
userId: { type: 'string', format: 'misskey:id', nullable: true }, userId: { type: 'string', format: 'misskey:id', nullable: true },
noIrregularRules: { type: 'boolean', default: false },
multiple: { type: 'boolean', default: false },
}, },
required: [], required: [],
} as const; } as const;
@ -56,7 +58,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err; throw err;
}) : null; }) : null;
const game = target ? await this.reversiService.matchSpecificUser(me, target) : await this.reversiService.matchAnyUser(me); const game = target
? await this.reversiService.matchSpecificUser(me, target, ps.multiple)
: await this.reversiService.matchAnyUser(me, { noIrregularRules: ps.noIrregularRules }, ps.multiple);
if (game == null) return; if (game == null) return;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 181 KiB

View File

@ -40,7 +40,7 @@
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.3.1", "chromatic": "10.5.0",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4", "cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
@ -51,7 +51,6 @@
"insert-text-at-cursor": "0.3.0", "insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2", "is-file-animated": "1.0.2",
"json5": "2.2.3", "json5": "2.2.3",
"lodash.defaultsdeep": "4.6.1",
"matter-js": "0.19.0", "matter-js": "0.19.0",
"mfm-js": "0.24.0", "mfm-js": "0.24.0",
"misskey-bubble-game": "workspace:*", "misskey-bubble-game": "workspace:*",
@ -101,10 +100,9 @@
"@testing-library/vue": "8.0.1", "@testing-library/vue": "8.0.1",
"@types/escape-regexp": "0.0.3", "@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5", "@types/estree": "1.0.5",
"@types/lodash.defaultsdeep": "4.6.9",
"@types/matter-js": "0.19.6", "@types/matter-js": "0.19.6",
"@types/micromatch": "4.0.6", "@types/micromatch": "4.0.6",
"@types/node": "20.11.5", "@types/node": "20.11.6",
"@types/punycode": "2.1.3", "@types/punycode": "2.1.3",
"@types/sanitize-html": "2.9.5", "@types/sanitize-html": "2.9.5",
"@types/throttle-debounce": "5.0.2", "@types/throttle-debounce": "5.0.2",
@ -125,7 +123,7 @@
"happy-dom": "10.0.3", "happy-dom": "10.0.3",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"micromatch": "4.0.5", "micromatch": "4.0.5",
"msw": "2.1.3", "msw": "2.1.4",
"msw-storybook-addon": "1.10.0", "msw-storybook-addon": "1.10.0",
"nodemon": "3.0.3", "nodemon": "3.0.3",
"prettier": "3.2.4", "prettier": "3.2.4",
@ -137,7 +135,7 @@
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6", "vitest": "0.34.6",
"vitest-fetch-mock": "0.2.2", "vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.4.1", "vue-eslint-parser": "9.4.2",
"vue-tsc": "1.8.27" "vue-tsc": "1.8.27"
} }
} }

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div <div
ref="rootEl" ref="rootEl"
:class="[$style.transitionRoot]" :class="[$style.transitionRoot, { [$style.enableAnimation]: shouldAnimate }]"
@touchstart.passive="touchStart" @touchstart.passive="touchStart"
@touchmove.passive="touchMove" @touchmove.passive="touchMove"
@touchend.passive="touchEnd" @touchend.passive="touchEnd"
@ -44,6 +44,8 @@ const emit = defineEmits<{
(ev: 'swiped', newKey: string, direction: 'left' | 'right'): void; (ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
}>(); }>();
const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value);
// // // //
// //
@ -188,7 +190,9 @@ watch(tabModel, (newTab, oldTab) => {
.transitionChildren { .transitionChildren {
grid-area: 1 / 1 / 2 / 2; grid-area: 1 / 1 / 2 / 2;
transform: translateX(var(--swipe)); transform: translateX(var(--swipe));
}
.enableAnimation .transitionChildren {
&.swipeAnimation_enterActive, &.swipeAnimation_enterActive,
&.swipeAnimation_leaveActive { &.swipeAnimation_leaveActive {
transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1); transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);

View File

@ -12,85 +12,96 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" :class="{ [$style.disallowInner]: isReady }"> <div class="_gaps" :class="{ [$style.disallowInner]: isReady }">
<div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div> <div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div>
<div class="_panel"> <template v-if="game.noIrregularRules">
<div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);"> <div>{{ i18n.ts._reversi.disallowIrregularRules }}</div>
<div>{{ mapName }}</div> </template>
<MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton> <template v-else>
</div> <div class="_panel">
<div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
<div>{{ mapName }}</div>
<MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
</div>
<div style="padding: 16px;"> <div style="padding: 16px;">
<div v-if="game.map == null"><i class="ti ti-dice"></i></div> <div v-if="game.map == null"><i class="ti ti-dice"></i></div>
<div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }"> <div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
<div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)"> <div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
<i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i> <i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ti ti-circle-filled' : 'ti ti-circle'"></i>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<MkFolder :defaultOpen="true"> <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.blackOrWhite }}</template> <template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
<MkRadios v-model="game.bw"> <MkRadios v-model="game.bw">
<option value="random">{{ i18n.ts.random }}</option> <option value="random">{{ i18n.ts.random }}</option>
<option :value="'1'"> <option :value="'1'">
<I18n :src="i18n.ts._reversi.blackIs" tag="span"> <I18n :src="i18n.ts._reversi.blackIs" tag="span">
<template #name> <template #name>
<b><MkUserName :user="game.user1"/></b> <b><MkUserName :user="game.user1"/></b>
</template> </template>
</I18n> </I18n>
</option> </option>
<option :value="'2'"> <option :value="'2'">
<I18n :src="i18n.ts._reversi.blackIs" tag="span"> <I18n :src="i18n.ts._reversi.blackIs" tag="span">
<template #name> <template #name>
<b><MkUserName :user="game.user2"/></b> <b><MkUserName :user="game.user2"/></b>
</template> </template>
</I18n> </I18n>
</option> </option>
</MkRadios> </MkRadios>
</MkFolder> </MkFolder>
<MkFolder :defaultOpen="true"> <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template> <template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template>
<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template> <template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
<MkRadios v-model="game.timeLimitForEachTurn"> <MkRadios v-model="game.timeLimitForEachTurn">
<option :value="5">5{{ i18n.ts._time.second }}</option> <option :value="5">5{{ i18n.ts._time.second }}</option>
<option :value="10">10{{ i18n.ts._time.second }}</option> <option :value="10">10{{ i18n.ts._time.second }}</option>
<option :value="30">30{{ i18n.ts._time.second }}</option> <option :value="30">30{{ i18n.ts._time.second }}</option>
<option :value="60">60{{ i18n.ts._time.second }}</option> <option :value="60">60{{ i18n.ts._time.second }}</option>
<option :value="90">90{{ i18n.ts._time.second }}</option> <option :value="90">90{{ i18n.ts._time.second }}</option>
<option :value="120">120{{ i18n.ts._time.second }}</option> <option :value="120">120{{ i18n.ts._time.second }}</option>
<option :value="180">180{{ i18n.ts._time.second }}</option> <option :value="180">180{{ i18n.ts._time.second }}</option>
<option :value="3600">3600{{ i18n.ts._time.second }}</option> <option :value="3600">3600{{ i18n.ts._time.second }}</option>
</MkRadios> </MkRadios>
</MkFolder> </MkFolder>
<MkFolder :defaultOpen="true"> <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._reversi.rules }}</template> <template #label>{{ i18n.ts._reversi.rules }}</template>
<div class="_gaps_s"> <div class="_gaps_s">
<MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch> <MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
<MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch> <MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
<MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch> <MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
</div> </div>
</MkFolder> </MkFolder>
</template>
</div> </div>
</div> </div>
</MkSpacer> </MkSpacer>
<template #footer> <template #footer>
<div :class="$style.footer"> <div :class="$style.footer">
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<div style="text-align: center; margin-bottom: 10px;"> <div style="text-align: center;" class="_gaps_s">
<template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template> <div v-if="opponentHasSettingsChanged" style="color: var(--warn);">{{ i18n.ts._reversi.opponentHasSettingsChanged }}</div>
<template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template> <div>
<template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template> <template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
<template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template> <template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template>
</div> <template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template>
<div class="_buttonsCenter"> <template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
<MkButton rounded danger @click="cancel">{{ i18n.ts.cancel }}</MkButton> </div>
<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton> <div class="_buttonsCenter">
<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton> <MkButton rounded danger @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
</div>
<div>
<MkSwitch v-model="shareWhenStart">{{ i18n.ts._reversi.shareToTlTheGameWhenStart }}</MkSwitch>
</div>
</div> </div>
</MkSpacer> </MkSpacer>
</div> </div>
@ -124,6 +135,8 @@ const props = defineProps<{
connection: Misskey.ChannelConnection; connection: Misskey.ChannelConnection;
}>(); }>();
const shareWhenStart = defineModel<boolean>('shareWhenStart', { default: false });
const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game)); const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
const mapName = computed(() => { const mapName = computed(() => {
@ -142,6 +155,8 @@ const isOpReady = computed(() => {
return false; return false;
}); });
const opponentHasSettingsChanged = ref(false);
watch(() => game.value.bw, () => { watch(() => game.value.bw, () => {
updateSettings('bw'); updateSettings('bw');
}); });
@ -190,6 +205,7 @@ async function cancel() {
function ready() { function ready() {
props.connection.send('ready', true); props.connection.send('ready', true);
opponentHasSettingsChanged.value = false;
} }
function unready() { function unready() {
@ -212,6 +228,10 @@ function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof M
if (userId === $i.id) return; if (userId === $i.id) return;
if (game.value[key] === value) return; if (game.value[key] === value) return;
game.value[key] = value; game.value[key] = value;
if (isReady.value) {
opponentHasSettingsChanged.value = true;
unready();
}
} }
function onMapCellClick(pos: number, pixel: string) { function onMapCellClick(pos: number, pixel: string) {

View File

@ -5,12 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div v-if="game == null || (!game.isEnded && connection == null)"><MkLoading/></div> <div v-if="game == null || (!game.isEnded && connection == null)"><MkLoading/></div>
<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection!"/> <GameSetting v-else-if="!game.isStarted" v-model:shareWhenStart="shareWhenStart" :game="game" :connection="connection!"/>
<GameBoard v-else :game="game" :connection="connection"/> <GameBoard v-else :game="game" :connection="connection"/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch, onMounted, shallowRef, onUnmounted } from 'vue'; import { computed, watch, onMounted, ref, shallowRef, onUnmounted } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import GameSetting from './game.setting.vue'; import GameSetting from './game.setting.vue';
import GameBoard from './game.board.vue'; import GameBoard from './game.board.vue';
@ -21,6 +21,7 @@ import { signinRequired } from '@/account.js';
import { useRouter } from '@/global/router/supplier.js'; import { useRouter } from '@/global/router/supplier.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useInterval } from '@/scripts/use-interval.js';
const $i = signinRequired(); const $i = signinRequired();
@ -32,17 +33,32 @@ const props = defineProps<{
const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null); const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null);
const connection = shallowRef<Misskey.ChannelConnection | null>(null); const connection = shallowRef<Misskey.ChannelConnection | null>(null);
const shareWhenStart = ref(false);
watch(() => props.gameId, () => { watch(() => props.gameId, () => {
fetchGame(); fetchGame();
}); });
function start(_game: Misskey.entities.ReversiGameDetailed) {
if (game.value?.isStarted) return;
if (shareWhenStart.value) {
misskeyApi('notes/create', {
text: i18n.ts._reversi.iStartedAGame + '\n' + location.href,
visibility: 'home',
});
}
game.value = _game;
}
async function fetchGame() { async function fetchGame() {
const _game = await misskeyApi('reversi/show-game', { const _game = await misskeyApi('reversi/show-game', {
gameId: props.gameId, gameId: props.gameId,
}); });
game.value = _game; game.value = _game;
shareWhenStart.value = false;
if (connection.value) { if (connection.value) {
connection.value.dispose(); connection.value.dispose();
@ -52,7 +68,7 @@ async function fetchGame() {
gameId: game.value.id, gameId: game.value.id,
}); });
connection.value.on('started', x => { connection.value.on('started', x => {
game.value = x.game; start(x.game);
}); });
connection.value.on('canceled', x => { connection.value.on('canceled', x => {
connection.value?.dispose(); connection.value?.dispose();
@ -68,6 +84,25 @@ async function fetchGame() {
} }
} }
//
useInterval(async () => {
if (game.value == null) return;
if (game.value.isStarted) return;
const _game = await misskeyApi('reversi/show-game', {
gameId: props.gameId,
});
if (_game.isStarted) {
start(_game);
} else {
game.value = _game;
}
}, 1000 * 10, {
immediate: false,
afterMounted: true,
});
onMounted(() => { onMounted(() => {
fetchGame(); fetchGame();
}); });
@ -78,10 +113,6 @@ onUnmounted(() => {
} }
}); });
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(computed(() => ({ definePageMetadata(computed(() => ({
title: 'Reversi', title: 'Reversi',
icon: 'ti ti-device-gamepad', icon: 'ti ti-device-gamepad',

View File

@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :pagination="myGamesPagination" :disableAutoLoad="true"> <MkPagination :pagination="myGamesPagination" :disableAutoLoad="true">
<template #default="{ items }"> <template #default="{ items }">
<div :class="$style.gamePreviews"> <div :class="$style.gamePreviews">
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`"> <MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isStarted && !g.isEnded && $style.gamePreviewWaiting, g.isStarted && !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
<div :class="$style.gamePreviewPlayers"> <div :class="$style.gamePreviewPlayers">
<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span> <span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span> <span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
@ -45,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span> <span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
</div> </div>
<div :class="$style.gamePreviewFooter"> <div :class="$style.gamePreviewFooter">
<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span> <span v-if="g.isStarted && !g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
<span v-else-if="!g.isEnded" :class="$style.gamePreviewStatusWaiting"><MkEllipsis/></span>
<span v-else>{{ i18n.ts._reversi.ended }}</span> <span v-else>{{ i18n.ts._reversi.ended }}</span>
<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/> <MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
</div> </div>
@ -60,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :pagination="gamesPagination" :disableAutoLoad="true"> <MkPagination :pagination="gamesPagination" :disableAutoLoad="true">
<template #default="{ items }"> <template #default="{ items }">
<div :class="$style.gamePreviews"> <div :class="$style.gamePreviews">
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`"> <MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isStarted && !g.isEnded && $style.gamePreviewWaiting, g.isStarted && !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
<div :class="$style.gamePreviewPlayers"> <div :class="$style.gamePreviewPlayers">
<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span> <span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span> <span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
@ -71,7 +72,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span> <span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
</div> </div>
<div :class="$style.gamePreviewFooter"> <div :class="$style.gamePreviewFooter">
<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span> <span v-if="g.isStarted && !g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
<span v-else-if="!g.isEnded" :class="$style.gamePreviewStatusWaiting"><MkEllipsis/></span>
<span v-else>{{ i18n.ts._reversi.ended }}</span> <span v-else>{{ i18n.ts._reversi.ended }}</span>
<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/> <MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
</div> </div>
@ -137,7 +139,9 @@ if ($i) {
const connection = useStream().useChannel('reversi'); const connection = useStream().useChannel('reversi');
connection.on('matched', x => { connection.on('matched', x => {
startGame(x.game); if (matchingUser.value != null || matchingAny.value) {
startGame(x.game);
}
}); });
connection.on('invited', invitation => { connection.on('invited', invitation => {
@ -153,6 +157,7 @@ if ($i) {
const invitations = ref<Misskey.entities.UserLite[]>([]); const invitations = ref<Misskey.entities.UserLite[]>([]);
const matchingUser = ref<Misskey.entities.UserLite | null>(null); const matchingUser = ref<Misskey.entities.UserLite | null>(null);
const matchingAny = ref<boolean>(false); const matchingAny = ref<boolean>(false);
const noIrregularRules = ref<boolean>(false);
function startGame(game: Misskey.entities.ReversiGameDetailed) { function startGame(game: Misskey.entities.ReversiGameDetailed) {
matchingUser.value = null; matchingUser.value = null;
@ -178,6 +183,7 @@ async function matchHeatbeat() {
} else if (matchingAny.value) { } else if (matchingAny.value) {
const res = await misskeyApi('reversi/match', { const res = await misskeyApi('reversi/match', {
userId: null, userId: null,
noIrregularRules: noIrregularRules.value,
}); });
if (res != null) { if (res != null) {
@ -195,10 +201,22 @@ async function matchUser() {
matchHeatbeat(); matchHeatbeat();
} }
async function matchAny() { function matchAny(ev: MouseEvent) {
matchingAny.value = true; os.popupMenu([{
text: i18n.ts._reversi.allowIrregularRules,
matchHeatbeat(); action: () => {
noIrregularRules.value = false;
matchingAny.value = true;
matchHeatbeat();
},
}, {
text: i18n.ts._reversi.disallowIrregularRules,
action: () => {
noIrregularRules.value = true;
matchingAny.value = true;
matchHeatbeat();
},
}], ev.currentTarget ?? ev.target);
} }
function cancelMatching() { function cancelMatching() {
@ -220,12 +238,14 @@ async function accept(user) {
} }
} }
useInterval(matchHeatbeat, 1000 * 10, { immediate: false, afterMounted: true }); useInterval(matchHeatbeat, 1000 * 5, { immediate: false, afterMounted: true });
onMounted(() => { onMounted(() => {
misskeyApi('reversi/invitations').then(_invitations => { misskeyApi('reversi/invitations').then(_invitations => {
invitations.value = _invitations; invitations.value = _invitations;
}); });
window.addEventListener('beforeunload', cancelMatching);
}); });
onDeactivated(() => { onDeactivated(() => {
@ -273,6 +293,10 @@ definePageMetadata(computed(() => ({
box-shadow: inset 0 0 8px 0px var(--accent); box-shadow: inset 0 0 8px 0px var(--accent);
} }
.gamePreviewWaiting {
box-shadow: inset 0 0 8px 0px var(--warn);
}
.gamePreviewPlayers { .gamePreviewPlayers {
text-align: center; text-align: center;
padding: 16px; padding: 16px;
@ -306,6 +330,12 @@ definePageMetadata(computed(() => ({
animation: blink 2s infinite; animation: blink 2s infinite;
} }
.gamePreviewStatusWaiting {
color: var(--warn);
font-weight: bold;
animation: blink 2s infinite;
}
.waitingScreen { .waitingScreen {
text-align: center; text-align: center;
} }

View File

@ -7,7 +7,6 @@
import { onUnmounted, Ref, ref, watch } from 'vue'; import { onUnmounted, Ref, ref, watch } from 'vue';
import { BroadcastChannel } from 'broadcast-channel'; import { BroadcastChannel } from 'broadcast-channel';
import defaultsDeep from 'lodash.defaultsdeep';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { get, set } from '@/scripts/idb-proxy.js'; import { get, set } from '@/scripts/idb-proxy.js';
@ -81,14 +80,37 @@ export class Storage<T extends StateDef> {
this.loaded = this.ready.then(() => this.load()); this.loaded = this.ready.then(() => this.load());
} }
private isPureObject(value: unknown): value is Record<string, unknown> { private isPureObject(value: unknown): value is Record<string | number | symbol, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
private mergeState<T>(value: T, def: T): T { /**
* valueにないキーをdefからもらう\
* nullはそのままundefinedはdefの値
**/
private mergeObject<X>(value: X, def: X): X {
if (this.isPureObject(value) && this.isPureObject(def)) { if (this.isPureObject(value) && this.isPureObject(def)) {
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def); const result = structuredClone(value) as X;
return defaultsDeep(value, def) as T; for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
result[k] = v;
} else if (this.isPureObject(v) && this.isPureObject(result[k])) {
const child = structuredClone(result[k]) as X[keyof X] & Record<string | number | symbol, unknown>;
result[k] = this.mergeObject<typeof v>(child, v);
}
}
return result;
}
return value;
}
private mergeState<X>(value: X, def: X): X {
if (this.isPureObject(value) && this.isPureObject(def)) {
const merged = this.mergeObject(value, def);
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);
return merged as X;
} }
return value; return value;
} }

View File

@ -26,7 +26,7 @@
"devDependencies": { "devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0", "@misskey-dev/eslint-plugin": "1.0.0",
"@types/matter-js": "0.19.6", "@types/matter-js": "0.19.6",
"@types/node": "20.11.5", "@types/node": "20.11.6",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1", "@typescript-eslint/parser": "6.18.1",
@ -38,7 +38,7 @@
"built" "built"
], ],
"dependencies": { "dependencies": {
"esbuild": "0.19.11", "esbuild": "0.19.12",
"eventemitter3": "5.0.1", "eventemitter3": "5.0.1",
"glob": "^10.3.10", "glob": "^10.3.10",
"matter-js": "0.19.0", "matter-js": "0.19.0",

View File

@ -9,7 +9,7 @@
"devDependencies": { "devDependencies": {
"@misskey-dev/eslint-plugin": "^1.0.0", "@misskey-dev/eslint-plugin": "^1.0.0",
"@readme/openapi-parser": "2.5.0", "@readme/openapi-parser": "2.5.0",
"@types/node": "20.11.5", "@types/node": "20.11.6",
"@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1", "@typescript-eslint/parser": "6.18.1",
"eslint": "8.56.0", "eslint": "8.56.0",

View File

@ -35,11 +35,11 @@
"url": "git+https://github.com/misskey-dev/misskey.js.git" "url": "git+https://github.com/misskey-dev/misskey.js.git"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/api-extractor": "7.39.1", "@microsoft/api-extractor": "7.39.3",
"@misskey-dev/eslint-plugin": "1.0.0", "@misskey-dev/eslint-plugin": "1.0.0",
"@swc/jest": "0.2.31", "@swc/jest": "0.2.31",
"@types/jest": "29.5.11", "@types/jest": "29.5.11",
"@types/node": "20.11.5", "@types/node": "20.11.6",
"@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1", "@typescript-eslint/parser": "6.18.1",
"eslint": "8.56.0", "eslint": "8.56.0",

View File

@ -4597,6 +4597,7 @@ export type components = {
timeoutUserId: string | null; timeoutUserId: string | null;
black: number | null; black: number | null;
bw: string; bw: string;
noIrregularRules: boolean;
isLlotheo: boolean; isLlotheo: boolean;
canPutEverywhere: boolean; canPutEverywhere: boolean;
loopedBoard: boolean; loopedBoard: boolean;
@ -4632,6 +4633,7 @@ export type components = {
timeoutUserId: string | null; timeoutUserId: string | null;
black: number | null; black: number | null;
bw: string; bw: string;
noIrregularRules: boolean;
isLlotheo: boolean; isLlotheo: boolean;
canPutEverywhere: boolean; canPutEverywhere: boolean;
loopedBoard: boolean; loopedBoard: boolean;
@ -26298,6 +26300,10 @@ export type operations = {
'application/json': { 'application/json': {
/** Format: misskey:id */ /** Format: misskey:id */
userId?: string | null; userId?: string | null;
/** @default false */
noIrregularRules?: boolean;
/** @default false */
multiple?: boolean;
}; };
}; };
}; };

View File

@ -25,7 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0", "@misskey-dev/eslint-plugin": "1.0.0",
"@types/node": "20.11.5", "@types/node": "20.11.6",
"@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1", "@typescript-eslint/parser": "6.18.1",
"eslint": "8.56.0", "eslint": "8.56.0",
@ -34,7 +34,7 @@
}, },
"dependencies": { "dependencies": {
"crc-32": "1.2.2", "crc-32": "1.2.2",
"esbuild": "0.19.11", "esbuild": "0.19.12",
"glob": "10.3.10" "glob": "10.3.10"
}, },
"files": [ "files": [

View File

@ -9,7 +9,7 @@
"lint": "pnpm typecheck && pnpm eslint" "lint": "pnpm typecheck && pnpm eslint"
}, },
"dependencies": { "dependencies": {
"esbuild": "0.19.11", "esbuild": "0.19.12",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",
"misskey-js": "workspace:*" "misskey-js": "workspace:*"
}, },

File diff suppressed because it is too large Load Diff