From 576484835ebe0c896b7fd242c7f8d58b32579da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 21 Jan 2024 05:26:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?enhance(frontend):=20=E3=83=8E=E3=83=BC?= =?UTF-8?q?=E3=83=88=E4=BD=9C=E6=88=90=E7=94=BB=E9=9D=A2=E3=81=AE=E6=B7=BB?= =?UTF-8?q?=E4=BB=98=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC=E3=81=8B=E3=82=89?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?=E6=B6=88=E3=81=9B=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#1285?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * (enhance) 添付画面から直接ファイルを消せるように * Update Changelog --------- Co-authored-by: syuilo --- CHANGELOG.md | 1 + locales/index.d.ts | 1 + locales/ja-JP.yml | 1 + .../src/components/MkPostFormAttaches.vue | 24 +++++++++++++++++++ 4 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6dd704c5b6d..993899ca2ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように - Enhance: Playの説明欄にMFMを使えるように - Enhance: チャンネルノートの場合は詳細ページからその前後のノートを見れるように +- Enhance: ノート作成画面のファイル添付メニューから直接ファイルを削除できるように - Enhance: MFMの属性でオートコンプリートが使用できるように #12735 - Fix: ネイティブモードの絵文字がモノクロにならないように - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index f7f952175fad..5656e9fbca4c 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -536,6 +536,7 @@ export interface Locale extends ILocale { * 添付取り消し */ "attachCancel": string; + "deleteFile": string; /** * センシティブとして設定 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6c8a453023c7..86f253c8c48e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -130,6 +130,7 @@ overwriteFromPinnedEmojis: "全般設定から上書きする" reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。" rememberNoteVisibility: "公開範囲を記憶する" attachCancel: "添付取り消し" +deleteFile: "ファイルを削除" markAsSensitive: "センシティブとして設定" unmarkAsSensitive: "センシティブを解除する" enterFileName: "ファイル名を入力" diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index 31dc48194eef..7e8b3b11673a 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -56,6 +56,23 @@ function detachMedia(id: string) { } } +async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) { + if (mock) return; + + detachMedia(file.id); + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('driveFileDeleteConfirm', { name: file.name }), + }); + + if (canceled) return; + + os.apiWithDialog('drive/files/delete', { + fileId: file.id, + }); +} + function toggleSensitive(file) { if (mock) { emit('changeSensitive', file, !file.isSensitive); @@ -138,6 +155,13 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void { text: i18n.ts.attachCancel, icon: 'ti ti-circle-x', action: () => { detachMedia(file.id); }, + }, { + type: 'divider', + }, { + text: i18n.ts.deleteFile, + icon: 'ti ti-trash', + danger: true, + action: () => { detachAndDeleteMedia(file); }, }], ev.currentTarget ?? ev.target).then(() => menuShowing = false); menuShowing = true; } From a17251d913c822e3113b47ed8135eecb3f06c445 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 21 Jan 2024 10:07:43 +0900 Subject: [PATCH 2/2] enhance(reversi): tweak reversi --- locales/index.d.ts | 11 + locales/ja-JP.yml | 2 + .../migration/1705793785675-reversi-3.js | 18 ++ .../migration/1705794768153-reversi-4.js | 18 ++ .../migration/1705798904141-reversi-5.js | 16 ++ .../backend/src/core/GlobalEventService.ts | 3 - packages/backend/src/core/ReversiService.ts | 240 ++++++++++++++---- .../core/entities/ReversiGameEntityService.ts | 10 +- packages/backend/src/models/ReversiGame.ts | 20 +- .../src/models/json-schema/reversi-game.ts | 32 ++- .../server/api/endpoints/reversi/surrender.ts | 2 +- .../api/stream/channels/reversi-game.ts | 49 ++-- .../frontend/src/pages/reversi/game.board.vue | 65 ++--- .../src/pages/reversi/game.setting.vue | 20 ++ .../misskey-js/src/autogen/apiClientJSDoc.ts | 2 +- packages/misskey-js/src/autogen/endpoint.ts | 2 +- packages/misskey-js/src/autogen/entities.ts | 2 +- packages/misskey-js/src/autogen/models.ts | 2 +- packages/misskey-js/src/autogen/types.ts | 16 +- 19 files changed, 395 insertions(+), 135 deletions(-) create mode 100644 packages/backend/migration/1705793785675-reversi-3.js create mode 100644 packages/backend/migration/1705794768153-reversi-4.js create mode 100644 packages/backend/migration/1705798904141-reversi-5.js diff --git a/locales/index.d.ts b/locales/index.d.ts index 5656e9fbca4c..6e763cda101d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -536,6 +536,9 @@ export interface Locale extends ILocale { * 添付取り消し */ "attachCancel": string; + /** + * ファイルを削除 + */ "deleteFile": string; /** * センシティブとして設定 @@ -9482,6 +9485,10 @@ export interface Locale extends ILocale { * 投了により */ "surrendered": string; + /** + * 時間切れ + */ + "timeout": string; /** * 引き分け */ @@ -9534,6 +9541,10 @@ export interface Locale extends ILocale { * どこでも置けるモード */ "canPutEverywhere": string; + /** + * 1ターンの時間制限 + */ + "timeLimitForEachTurn": string; /** * フリーマッチ */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 86f253c8c48e..fd1c891ee750 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2527,6 +2527,7 @@ _reversi: pastTurnOf: "{name}のターン" surrender: "投了" surrendered: "投了により" + timeout: "時間切れ" drawn: "引き分け" won: "{name}の勝ち" black: "黒" @@ -2540,5 +2541,6 @@ _reversi: isLlotheo: "石の少ない方が勝ち(ロセオ)" loopedMap: "ループマップ" canPutEverywhere: "どこでも置けるモード" + timeLimitForEachTurn: "1ターンの時間制限" freeMatch: "フリーマッチ" lookingForPlayer: "対戦相手を探しています" diff --git a/packages/backend/migration/1705793785675-reversi-3.js b/packages/backend/migration/1705793785675-reversi-3.js new file mode 100644 index 000000000000..2faf9ae6d5ed --- /dev/null +++ b/packages/backend/migration/1705793785675-reversi-3.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Reversi31705793785675 { + name = 'Reversi31705793785675' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "surrendered" TO "surrenderedUserId"`); + await queryRunner.query(`ALTER TABLE "reversi_game" ADD "timeoutUserId" character varying(32)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "timeoutUserId"`); + await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "surrenderedUserId" TO "surrendered"`); + } +} diff --git a/packages/backend/migration/1705794768153-reversi-4.js b/packages/backend/migration/1705794768153-reversi-4.js new file mode 100644 index 000000000000..5b7bacb21e9b --- /dev/null +++ b/packages/backend/migration/1705794768153-reversi-4.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Reversi41705794768153 { + name = 'Reversi41705794768153' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "reversi_game" ADD "endedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`COMMENT ON COLUMN "reversi_game"."endedAt" IS 'The ended date of the ReversiGame.'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "reversi_game"."endedAt" IS 'The ended date of the ReversiGame.'`); + await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "endedAt"`); + } +} diff --git a/packages/backend/migration/1705798904141-reversi-5.js b/packages/backend/migration/1705798904141-reversi-5.js new file mode 100644 index 000000000000..7ca722160498 --- /dev/null +++ b/packages/backend/migration/1705798904141-reversi-5.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Reversi51705798904141 { + name = 'Reversi51705798904141' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "reversi_game" ADD "timeLimitForEachTurn" smallint NOT NULL DEFAULT '90'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "timeLimitForEachTurn"`); + } +} diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index e599912e2bba..5ddd100e6c9a 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -181,9 +181,6 @@ export interface ReversiGameEventTypes { value: any; }; log: Reversi.Serializer.Log & { id: string | null }; - heatbeat: { - userId: MiUser['id']; - }; started: { game: Packed<'ReversiGameDetailed'>; }; diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index e626cbaf19e3..b2a4032d4b65 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -25,6 +25,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import type { Packed } from '@/misc/json-schema.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { Serialized } from '@/types.js'; import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; @@ -55,6 +56,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { this.notificationService = this.moduleRef.get(NotificationService.name); } + @bindThis + private async cacheGame(game: MiReversiGame) { + await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 3, JSON.stringify(game)); + } + @bindThis public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise { if (targetUser.id === me.id) { @@ -83,6 +89,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { bw: 'random', isLlotheo: false, }).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0])); + this.cacheGame(game); const packed = await this.reversiGameEntityService.packDetail(game, { id: targetUser.id }); this.globalEventService.publishReversiStream(targetUser.id, 'matched', { game: packed }); @@ -125,6 +132,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { bw: 'random', isLlotheo: false, }).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0])); + this.cacheGame(game); const packed = await this.reversiGameEntityService.packDetail(game, { id: invitorId }); this.globalEventService.publishReversiStream(invitorId, 'matched', { game: packed }); @@ -160,6 +168,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { bw: 'random', isLlotheo: false, }).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0])); + this.cacheGame(game); const packed = await this.reversiGameEntityService.packDetail(game, { id: matchedUserId }); this.globalEventService.publishReversiStream(matchedUserId, 'matched', { game: packed }); @@ -182,33 +191,47 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async gameReady(game: MiReversiGame, user: MiUser, ready: boolean) { + public async gameReady(gameId: MiReversiGame['id'], user: MiUser, ready: boolean) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); if (game.isStarted) return; let isBothReady = false; if (game.user1Id === user.id) { - await this.reversiGamesRepository.update(game.id, { - user1Ready: ready, - }); + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + user1Ready: ready, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', { user1: ready, - user2: game.user2Ready, + user2: updatedGame.user2Ready, }); - if (ready && game.user2Ready) isBothReady = true; + if (ready && updatedGame.user2Ready) isBothReady = true; } else if (game.user2Id === user.id) { - await this.reversiGamesRepository.update(game.id, { - user2Ready: ready, - }); + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + user2Ready: ready, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', { - user1: game.user1Ready, + user1: updatedGame.user1Ready, user2: ready, }); - if (ready && game.user1Ready) isBothReady = true; + if (ready && updatedGame.user1Ready) isBothReady = true; } else { return; } @@ -237,45 +260,62 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { const crc32 = CRC32.str(JSON.stringify(freshGame.logs)).toString(); - await this.reversiGamesRepository.update(game.id, { - startedAt: new Date(), - isStarted: true, - black: bw, - map: map, - crc32, - }); + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + startedAt: new Date(), + isStarted: true, + black: bw, + map: map, + crc32, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 - const o = new Reversi.Game(map, { + const engine = new Reversi.Game(map, { isLlotheo: freshGame.isLlotheo, canPutEverywhere: freshGame.canPutEverywhere, loopedBoard: freshGame.loopedBoard, }); - if (o.isEnded) { + if (engine.isEnded) { let winner; - if (o.winner === true) { - winner = freshGame.black === 1 ? freshGame.user1Id : freshGame.user2Id; - } else if (o.winner === false) { - winner = freshGame.black === 1 ? freshGame.user2Id : freshGame.user1Id; + if (engine.winner === true) { + winner = bw === 1 ? freshGame.user1Id : freshGame.user2Id; + } else if (engine.winner === false) { + winner = bw === 1 ? freshGame.user2Id : freshGame.user1Id; } else { winner = null; } - await this.reversiGamesRepository.update(game.id, { - isEnded: true, - winnerId: winner, - }); + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + isEnded: true, + endedAt: new Date(), + winnerId: winner, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); this.globalEventService.publishReversiGameStream(game.id, 'ended', { winnerId: winner, - game: await this.reversiGameEntityService.packDetail(game.id, user), + game: await this.reversiGameEntityService.packDetail(game.id), }); + + return; } //#endregion + this.redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, updatedGame.timeLimitForEachTurn, ''); + this.globalEventService.publishReversiGameStream(game.id, 'started', { - game: await this.reversiGameEntityService.packDetail(game.id, user), + game: await this.reversiGameEntityService.packDetail(game.id), }); }, 3000); } @@ -292,17 +332,27 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async updateSettings(game: MiReversiGame, user: MiUser, key: string, value: any) { + public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: string, value: any) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); if (game.isStarted) return; if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return; if ((game.user1Id === user.id) && game.user1Ready) return; if ((game.user2Id === user.id) && game.user2Ready) return; - if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard'].includes(key)) return; + if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn'].includes(key)) return; - await this.reversiGamesRepository.update(game.id, { - [key]: value, - }); + // TODO: より厳格なバリデーション + + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + [key]: value, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); this.globalEventService.publishReversiGameStream(game.id, 'updateSettings', { userId: user.id, @@ -312,7 +362,9 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async putStoneToGame(game: MiReversiGame, user: MiUser, pos: number, id?: string | null) { + public async putStoneToGame(gameId: MiReversiGame['id'], user: MiUser, pos: number, id?: string | null) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); if (!game.isStarted) return; if (game.isEnded) return; if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return; @@ -361,12 +413,18 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { const crc32 = CRC32.str(JSON.stringify(serializeLogs)).toString(); - await this.reversiGamesRepository.update(game.id, { - crc32, - isEnded: engine.isEnded, - winnerId: winner, - logs: serializeLogs, - }); + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + crc32, + isEnded: engine.isEnded, + winnerId: winner, + logs: serializeLogs, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); this.globalEventService.publishReversiGameStream(game.id, 'log', { ...log, @@ -376,38 +434,112 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { if (engine.isEnded) { this.globalEventService.publishReversiGameStream(game.id, 'ended', { winnerId: winner ?? null, - game: await this.reversiGameEntityService.packDetail(game.id, user), + game: await this.reversiGameEntityService.packDetail(game.id), }); + } else { + this.redisClient.setex(`reversi:game:turnTimer:${game.id}:${engine.turn ? '1' : '0'}`, updatedGame.timeLimitForEachTurn, ''); } } @bindThis - public async surrender(game: MiReversiGame, user: MiUser) { + public async surrender(gameId: MiReversiGame['id'], user: MiUser) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); if (game.isEnded) return; if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return; const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id; - await this.reversiGamesRepository.update(game.id, { - surrendered: user.id, - isEnded: true, - winnerId: winnerId, - }); + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + isEnded: true, + endedAt: new Date(), + winnerId: winnerId, + surrenderedUserId: user.id, + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); this.globalEventService.publishReversiGameStream(game.id, 'ended', { winnerId: winnerId, - game: await this.reversiGameEntityService.packDetail(game.id, user), + game: await this.reversiGameEntityService.packDetail(game.id), }); } @bindThis - public async get(id: MiReversiGame['id']) { - return this.reversiGamesRepository.findOneBy({ id }); + public async checkTimeout(gameId: MiReversiGame['id']) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); + if (game.isEnded) return; + + const engine = Reversi.Serializer.restoreGame({ + map: game.map, + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard, + logs: game.logs, + }); + + if (engine.turn == null) return; + + const timer = await this.redisClient.exists(`reversi:game:turnTimer:${game.id}:${engine.turn ? '1' : '0'}`); + + if (timer === 0) { + const winnerId = engine.turn ? (game.black === 1 ? game.user2Id : game.user1Id) : (game.black === 1 ? game.user1Id : game.user2Id); + + const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update() + .set({ + isEnded: true, + endedAt: new Date(), + winnerId: winnerId, + timeoutUserId: engine.turn ? (game.black === 1 ? game.user1Id : game.user2Id) : (game.black === 1 ? game.user2Id : game.user1Id), + }) + .where('id = :id', { id: game.id }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + this.cacheGame(updatedGame); + + this.globalEventService.publishReversiGameStream(game.id, 'ended', { + winnerId: winnerId, + game: await this.reversiGameEntityService.packDetail(game.id), + }); + } + } + + @bindThis + public async get(id: MiReversiGame['id']): Promise { + const cached = await this.redisClient.get(`reversi:game:cache:${id}`); + if (cached != null) { + const parsed = JSON.parse(cached) as Serialized; + return { + ...parsed, + startedAt: parsed.startedAt != null ? new Date(parsed.startedAt) : null, + endedAt: parsed.endedAt != null ? new Date(parsed.endedAt) : null, + }; + } else { + const game = await this.reversiGamesRepository.findOneBy({ id }); + if (game == null) return null; + + this.cacheGame(game); + + return game; + } } @bindThis - public async heatbeat(game: MiReversiGame, user: MiUser) { - this.globalEventService.publishReversiGameStream(game.id, 'heatbeat', { userId: user.id }); + public async checkCrc(gameId: MiReversiGame['id'], crc32: string | number) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); + + if (crc32.toString() !== game.crc32) { + return await this.reversiGameEntityService.packDetail(game); + } else { + return null; + } } @bindThis diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index a7adc681f67f..bcb0fd5a6f14 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -37,6 +37,7 @@ export class ReversiGameEntityService { id: game.id, createdAt: this.idService.parse(game.id).date.toISOString(), startedAt: game.startedAt && game.startedAt.toISOString(), + endedAt: game.endedAt && game.endedAt.toISOString(), isStarted: game.isStarted, isEnded: game.isEnded, form1: game.form1, @@ -49,12 +50,14 @@ export class ReversiGameEntityService { user2: this.userEntityService.pack(game.user2Id, me), winnerId: game.winnerId, winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null, - surrendered: game.surrendered, + surrenderedUserId: game.surrenderedUserId, + timeoutUserId: game.timeoutUserId, black: game.black, bw: game.bw, isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, + timeLimitForEachTurn: game.timeLimitForEachTurn, logs: game.logs, map: game.map, }); @@ -79,6 +82,7 @@ export class ReversiGameEntityService { id: game.id, createdAt: this.idService.parse(game.id).date.toISOString(), startedAt: game.startedAt && game.startedAt.toISOString(), + endedAt: game.endedAt && game.endedAt.toISOString(), isStarted: game.isStarted, isEnded: game.isEnded, form1: game.form1, @@ -91,12 +95,14 @@ export class ReversiGameEntityService { user2: this.userEntityService.pack(game.user2Id, me), winnerId: game.winnerId, winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null, - surrendered: game.surrendered, + surrenderedUserId: game.surrenderedUserId, + timeoutUserId: game.timeoutUserId, black: game.black, bw: game.bw, isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, + timeLimitForEachTurn: game.timeLimitForEachTurn, }); } diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts index dcaa5c9fa9f8..11d236e4588a 100644 --- a/packages/backend/src/models/ReversiGame.ts +++ b/packages/backend/src/models/ReversiGame.ts @@ -13,6 +13,12 @@ export class MiReversiGame { }) public startedAt: Date | null; + @Column('timestamp with time zone', { + nullable: true, + comment: 'The ended date of the ReversiGame.', + }) + public endedAt: Date | null; + @Column(id()) public user1Id: MiUser['id']; @@ -71,7 +77,19 @@ export class MiReversiGame { ...id(), nullable: true, }) - public surrendered: MiUser['id'] | null; + public surrenderedUserId: MiUser['id'] | null; + + @Column({ + ...id(), + nullable: true, + }) + public timeoutUserId: MiUser['id'] | null; + + // in sec + @Column('smallint', { + default: 90, + }) + public timeLimitForEachTurn: number; @Column('jsonb', { default: [], diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts index b94046438b5c..4ac4d165d803 100644 --- a/packages/backend/src/models/json-schema/reversi-game.ts +++ b/packages/backend/src/models/json-schema/reversi-game.ts @@ -21,6 +21,11 @@ export const packedReversiGameLiteSchema = { optional: false, nullable: true, format: 'date-time', }, + endedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, isStarted: { type: 'boolean', optional: false, nullable: false, @@ -75,7 +80,12 @@ export const packedReversiGameLiteSchema = { optional: false, nullable: true, ref: 'User', }, - surrendered: { + surrenderedUserId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + timeoutUserId: { type: 'string', optional: false, nullable: true, format: 'id', @@ -100,6 +110,10 @@ export const packedReversiGameLiteSchema = { type: 'boolean', optional: false, nullable: false, }, + timeLimitForEachTurn: { + type: 'number', + optional: false, nullable: false, + }, }, } as const; @@ -121,6 +135,11 @@ export const packedReversiGameDetailedSchema = { optional: false, nullable: true, format: 'date-time', }, + endedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, isStarted: { type: 'boolean', optional: false, nullable: false, @@ -175,7 +194,12 @@ export const packedReversiGameDetailedSchema = { optional: false, nullable: true, ref: 'User', }, - surrendered: { + surrenderedUserId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + timeoutUserId: { type: 'string', optional: false, nullable: true, format: 'id', @@ -200,6 +224,10 @@ export const packedReversiGameDetailedSchema = { type: 'boolean', optional: false, nullable: false, }, + timeLimitForEachTurn: { + type: 'number', + optional: false, nullable: false, + }, logs: { type: 'array', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/reversi/surrender.ts b/packages/backend/src/server/api/endpoints/reversi/surrender.ts index c47d36be3342..c809142e0753 100644 --- a/packages/backend/src/server/api/endpoints/reversi/surrender.ts +++ b/packages/backend/src/server/api/endpoints/reversi/surrender.ts @@ -62,7 +62,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.accessDenied); } - await this.reversiService.surrender(game, me); + await this.reversiService.surrender(game.id, me); }); } } diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index c5d05e5cfb0c..77eaa6d1d3e3 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -32,11 +32,6 @@ class ReversiGameChannel extends Channel { public async init(params: any) { this.gameId = params.gameId as string; - const game = await this.reversiGamesRepository.findOneBy({ - id: this.gameId, - }); - if (game == null) return; - this.subscriber.on(`reversiGameStream:${this.gameId}`, this.send); } @@ -46,7 +41,8 @@ class ReversiGameChannel extends Channel { case 'ready': this.ready(body); break; case 'updateSettings': this.updateSettings(body.key, body.value); break; case 'putStone': this.putStone(body.pos, body.id); break; - case 'heatbeat': this.heatbeat(body.crc32); break; + case 'checkState': this.checkState(body.crc32); break; + case 'claimTimeIsUp': this.claimTimeIsUp(); break; } } @@ -54,51 +50,38 @@ class ReversiGameChannel extends Channel { private async updateSettings(key: string, value: any) { if (this.user == null) return; - // TODO: キャッシュしたい - const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! }); - if (game == null) throw new Error('game not found'); - - this.reversiService.updateSettings(game, this.user, key, value); + this.reversiService.updateSettings(this.gameId!, this.user, key, value); } @bindThis private async ready(ready: boolean) { if (this.user == null) return; - const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! }); - if (game == null) throw new Error('game not found'); - - this.reversiService.gameReady(game, this.user, ready); + this.reversiService.gameReady(this.gameId!, this.user, ready); } @bindThis private async putStone(pos: number, id: string) { if (this.user == null) return; - // TODO: キャッシュしたい - const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! }); - if (game == null) throw new Error('game not found'); - - this.reversiService.putStoneToGame(game, this.user, pos, id); + this.reversiService.putStoneToGame(this.gameId!, this.user, pos, id); } @bindThis - private async heatbeat(crc32?: string | number | null) { - // TODO: キャッシュしたい - const game = await this.reversiGamesRepository.findOneBy({ id: this.gameId! }); - if (game == null) throw new Error('game not found'); - - if (!game.isStarted) return; + private async checkState(crc32: string | number) { + if (crc32 != null) return; - if (crc32 != null) { - if (crc32.toString() !== game.crc32) { - this.send('rescue', await this.reversiGameEntityService.packDetail(game, this.user)); - } + const game = await this.reversiService.checkCrc(this.gameId!, crc32); + if (game) { + this.send('rescue', game); } + } - if (this.user && (game.user1Id === this.user.id || game.user2Id === this.user.id)) { - this.reversiService.heatbeat(game, this.user); - } + @bindThis + private async claimTimeIsUp() { + if (this.user == null) return; + + this.reversiService.checkTimeout(this.gameId!); } @bindThis diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 2f09cf39e8d6..5e28f559021b 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -15,19 +15,20 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
-
+
-
{{ i18n.ts._reversi.opponentTurn }}({{ i18n.ts.notResponding }})
-
{{ i18n.ts._reversi.myTurn }}
-
+
{{ i18n.ts._reversi.opponentTurn }}({{ i18n.tsx.remainingN({ n: opTurnTimerRmain }) }})
+
{{ i18n.ts._reversi.myTurn }}({{ i18n.tsx.remainingN({ n: myTurnTimerRmain }) }})
+
@@ -239,7 +240,7 @@ if (game.value.isStarted && !game.value.isEnded) { if (game.value.isEnded) return; const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString(); if (_DEV_) console.log('crc32', crc32); - props.connection.send('heatbeat', { + props.connection.send('checkState', { crc32: crc32, }); }, 10000, { immediate: false, afterMounted: true }); @@ -269,9 +270,31 @@ function putStone(pos) { }); appliedOps.push(id); + myTurnTimerRmain.value = game.value.timeLimitForEachTurn; + opTurnTimerRmain.value = game.value.timeLimitForEachTurn; + checkEnd(); } +const myTurnTimerRmain = ref(game.value.timeLimitForEachTurn); +const opTurnTimerRmain = ref(game.value.timeLimitForEachTurn); + +const TIMER_INTERVAL_SEC = 3; +useInterval(() => { + if (myTurnTimerRmain.value > 0) { + myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC); + } + if (opTurnTimerRmain.value > 0) { + opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC); + } + + if (iAmPlayer.value) { + if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) { + props.connection.send('claimTimeIsUp', {}); + } + } +}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true }); + function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) { game.value.logs = Reversi.Serializer.serializeLogs([ ...Reversi.Serializer.deserializeLogs(game.value.logs), @@ -286,6 +309,9 @@ function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) { engine.value.putStone(log.pos); triggerRef(engine); + myTurnTimerRmain.value = game.value.timeLimitForEachTurn; + opTurnTimerRmain.value = game.value.timeLimitForEachTurn; + sound.playUrl('/client-assets/reversi/put.mp3', { volume: 1, playbackRate: 1, @@ -339,27 +365,6 @@ function onStreamRescue(_game) { checkEnd(); } -const opponentLastHeatbeatedAt = ref(Date.now()); -const opponentNotResponding = ref(false); - -useInterval(() => { - if (game.value.isEnded) return; - if (!iAmPlayer.value) return; - - if (Date.now() - opponentLastHeatbeatedAt.value > 20000) { - opponentNotResponding.value = true; - } else { - opponentNotResponding.value = false; - } -}, 1000, { immediate: false, afterMounted: true }); - -function onStreamHeatbeat({ userId }) { - if ($i.id === userId) return; - - opponentNotResponding.value = false; - opponentLastHeatbeatedAt.value = Date.now(); -} - async function surrender() { const { canceled } = await os.confirm({ type: 'warning', @@ -411,28 +416,24 @@ function share() { onMounted(() => { props.connection.on('log', onStreamLog); - props.connection.on('heatbeat', onStreamHeatbeat); props.connection.on('rescue', onStreamRescue); props.connection.on('ended', onStreamEnded); }); onActivated(() => { props.connection.on('log', onStreamLog); - props.connection.on('heatbeat', onStreamHeatbeat); props.connection.on('rescue', onStreamRescue); props.connection.on('ended', onStreamEnded); }); onDeactivated(() => { props.connection.off('log', onStreamLog); - props.connection.off('heatbeat', onStreamHeatbeat); props.connection.off('rescue', onStreamRescue); props.connection.off('ended', onStreamEnded); }); onUnmounted(() => { props.connection.off('log', onStreamLog); - props.connection.off('heatbeat', onStreamHeatbeat); props.connection.off('rescue', onStreamRescue); props.connection.off('ended', onStreamEnded); }); diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 301a177de13b..360b75745c5e 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -49,6 +49,22 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + + + + + + + + @@ -125,6 +141,10 @@ watch(() => game.value.bw, () => { updateSettings('bw'); }); +watch(() => game.value.timeLimitForEachTurn, () => { + updateSettings('timeLimitForEachTurn'); +}); + function chooseMap(ev: MouseEvent) { const menu: MenuItem[] = []; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index dc4bcd3aaab2..ea41f2cb55c9 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1,6 +1,6 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-20T04:59:59.768Z + * generatedAt: 2024-01-21T01:01:12.332Z */ import type { SwitchCaseResponseType } from '../api.js'; diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index edf0e34b2a7c..f55105352409 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -1,6 +1,6 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-20T04:59:59.766Z + * generatedAt: 2024-01-21T01:01:12.330Z */ import type { diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index ecf7e7f0793c..b0adbeaf9384 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -1,6 +1,6 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-20T04:59:59.765Z + * generatedAt: 2024-01-21T01:01:12.328Z */ import { operations } from './types.js'; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 561cfd861f2b..306f0cd6b4d5 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -1,6 +1,6 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-20T04:59:59.764Z + * generatedAt: 2024-01-21T01:01:12.327Z */ import { components } from './types.js'; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index e452636e8038..5d2b6e2e3b82 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3,7 +3,7 @@ /* * version: 2023.12.2 - * generatedAt: 2024-01-20T04:59:59.681Z + * generatedAt: 2024-01-21T01:01:12.246Z */ /** @@ -4465,6 +4465,8 @@ export type components = { createdAt: string; /** Format: date-time */ startedAt: string | null; + /** Format: date-time */ + endedAt: string | null; isStarted: boolean; isEnded: boolean; form1: Record | null; @@ -4481,12 +4483,15 @@ export type components = { winnerId: string | null; winner: components['schemas']['User'] | null; /** Format: id */ - surrendered: string | null; + surrenderedUserId: string | null; + /** Format: id */ + timeoutUserId: string | null; black: number | null; bw: string; isLlotheo: boolean; canPutEverywhere: boolean; loopedBoard: boolean; + timeLimitForEachTurn: number; }; ReversiGameDetailed: { /** Format: id */ @@ -4495,6 +4500,8 @@ export type components = { createdAt: string; /** Format: date-time */ startedAt: string | null; + /** Format: date-time */ + endedAt: string | null; isStarted: boolean; isEnded: boolean; form1: Record | null; @@ -4511,12 +4518,15 @@ export type components = { winnerId: string | null; winner: components['schemas']['User'] | null; /** Format: id */ - surrendered: string | null; + surrenderedUserId: string | null; + /** Format: id */ + timeoutUserId: string | null; black: number | null; bw: string; isLlotheo: boolean; canPutEverywhere: boolean; loopedBoard: boolean; + timeLimitForEachTurn: number; logs: unknown[][]; map: string[]; };