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 01/35] =?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 e6dd704c5b..993899ca2f 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 f7f952175f..5656e9fbca 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 6c8a453023..86f253c8c4 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 31dc48194e..7e8b3b1167 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 02/35] 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 5656e9fbca..6e763cda10 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 86f253c8c4..fd1c891ee7 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 0000000000..2faf9ae6d5 --- /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 0000000000..5b7bacb21e --- /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 0000000000..7ca7221604 --- /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 e599912e2b..5ddd100e6c 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 e626cbaf19..b2a4032d4b 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 heatbeat(game: MiReversiGame, user: MiUser) { - this.globalEventService.publishReversiGameStream(game.id, 'heatbeat', { userId: user.id }); + 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 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 a7adc681f6..bcb0fd5a6f 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 dcaa5c9fa9..11d236e458 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 b94046438b..4ac4d165d8 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 c47d36be33..c809142e07 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 c5d05e5cfb..77eaa6d1d3 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'); + private async checkState(crc32: string | number) { + if (crc32 != null) return; - if (!game.isStarted) 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 2f09cf39e8..5e28f55902 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 301a177de1..360b75745c 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 dc4bcd3aaa..ea41f2cb55 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 edf0e34b2a..f551053524 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 ecf7e7f079..b0adbeaf93 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 561cfd861f..306f0cd6b4 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 e452636e80..5d2b6e2e3b 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[]; }; From 4de77784c9ee5bf787e7bda41aae84d81edff78a 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 11:50:05 +0900 Subject: [PATCH 03/35] =?UTF-8?q?enhance(sw):=20=E3=82=AA=E3=83=95?= =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=83=B3=E8=A1=A8=E7=A4=BA=E3=81=AE=E3=83=87?= =?UTF-8?q?=E3=82=B6=E3=82=A4=E3=83=B3=E3=82=92=E6=94=B9=E5=96=84=20(#1305?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(sw): オフライン表示のデザインを改善 * Update Changelog * fix * fix * fix * 言語が取得できなかった場合のフォールバックを追加 * (change) translation key --- CHANGELOG.md | 3 +++ locales/index.d.ts | 10 ++++++++++ locales/ja-JP.yml | 4 ++++ packages/sw/src/sw.ts | 17 +++++++++++++---- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 993899ca2f..716937d628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,9 @@ - Fix: ipv4とipv6の両方が利用可能な環境でallowedPrivateNetworksが設定されていた場合プライベートipの検証ができていなかった問題を修正 - Fix: properly handle cc followers +### Service Worker +- Enhance: オフライン表示のデザインを改善・多言語対応 + ## 2023.12.2 ### General diff --git a/locales/index.d.ts b/locales/index.d.ts index 6e763cda10..910b1edad8 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9554,6 +9554,16 @@ export interface Locale extends ILocale { */ "lookingForPlayer": string; }; + "_offlineScreen": { + /** + * オフライン - サーバーに接続できません + */ + "title": string; + /** + * サーバーに接続できません + */ + "header": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fd1c891ee7..6460397db7 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2544,3 +2544,7 @@ _reversi: timeLimitForEachTurn: "1ターンの時間制限" freeMatch: "フリーマッチ" lookingForPlayer: "対戦相手を探しています" + +_offlineScreen: + title: "オフライン - サーバーに接続できません" + header: "サーバーに接続できません" diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index b79fd8ce7a..f423eaca20 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -6,6 +6,7 @@ import { get } from 'idb-keyval'; import * as Misskey from 'misskey-js'; import type { PushNotificationDataMap } from '@/types.js'; +import type { I18n, Locale } from '@/scripts/i18n.js'; import { createEmptyNotification, createNotification } from '@/scripts/create-notification.js'; import { swLang } from '@/scripts/lang.js'; import * as swos from '@/scripts/operations.js'; @@ -26,8 +27,15 @@ globalThis.addEventListener('activate', ev => { ); }); -function offlineContentHTML(): string { - return `Offline. Service Worker @${_VERSION_} `; +async function offlineContentHTML() { + const i18n = await (swLang.i18n ?? swLang.fetchLocale()) as Partial>; + const messages = { + title: i18n.ts?._offlineScreen?.title ?? 'Offline - Could not connect to server', + header: i18n.ts?._offlineScreen?.header ?? 'Could not connect to server', + reload: i18n.ts?.reload ?? 'Reload', + }; + + return `${messages.title}
${messages.header}
v${_VERSION_}
`; } globalThis.addEventListener('fetch', ev => { @@ -43,8 +51,9 @@ globalThis.addEventListener('fetch', ev => { if (!isHTMLRequest) return; ev.respondWith( fetch(ev.request) - .catch(() => { - return new Response(offlineContentHTML(), { + .catch(async () => { + const html = await offlineContentHTML(); + return new Response(html, { status: 200, headers: { 'content-type': 'text/html', From 6039f27bd50ef1fbbbe6bffe12b18614c9e5b85c Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 21 Jan 2024 12:05:51 +0900 Subject: [PATCH 04/35] enhance(reversi): tweak reversi --- locales/index.d.ts | 4 + locales/ja-JP.yml | 1 + .../backend/src/core/GlobalEventService.ts | 3 + packages/backend/src/core/ReversiService.ts | 175 ++++++++++-------- .../api/stream/channels/reversi-game.ts | 8 + .../src/pages/reversi/game.setting.vue | 17 +- packages/frontend/src/pages/reversi/game.vue | 19 ++ 7 files changed, 149 insertions(+), 78 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index 910b1edad8..5e00e539f2 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9553,6 +9553,10 @@ export interface Locale extends ILocale { * 対戦相手を探しています */ "lookingForPlayer": string; + /** + * 対局がキャンセルされました + */ + "gameCanceled": string; }; "_offlineScreen": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6460397db7..915b9a2080 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2544,6 +2544,7 @@ _reversi: timeLimitForEachTurn: "1ターンの時間制限" freeMatch: "フリーマッチ" lookingForPlayer: "対戦相手を探しています" + gameCanceled: "対局がキャンセルされました" _offlineScreen: title: "オフライン - サーバーに接続できません" diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 5ddd100e6c..5b4c8cb44f 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -188,6 +188,9 @@ export interface ReversiGameEventTypes { winnerId: MiUser['id'] | null; game: Packed<'ReversiGameDetailed'>; }; + canceled: { + userId: MiUser['id']; + }; } //#endregion diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index b2a4032d4b..f97f71eb43 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -61,6 +61,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 3, JSON.stringify(game)); } + @bindThis + private async deleteGameCache(gameId: MiReversiGame['id']) { + await this.redisClient.del(`reversi:game:cache:${gameId}`); + } + @bindThis public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise { if (targetUser.id === me.id) { @@ -239,88 +244,93 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { if (isBothReady) { // 3秒後、両者readyならゲーム開始 setTimeout(async () => { - const freshGame = await this.reversiGamesRepository.findOneBy({ id: game.id }); + const freshGame = await this.get(game.id); if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; if (!freshGame.user1Ready || !freshGame.user2Ready) return; - let bw: number; - if (freshGame.bw === 'random') { - bw = Math.random() > 0.5 ? 1 : 2; - } else { - bw = parseInt(freshGame.bw, 10); - } - - function getRandomMap() { - const mapCount = Object.entries(Reversi.maps).length; - const rnd = Math.floor(Math.random() * mapCount); - return Object.values(Reversi.maps)[rnd].data; - } - - const map = freshGame.map != null ? freshGame.map : getRandomMap(); - - const crc32 = CRC32.str(JSON.stringify(freshGame.logs)).toString(); - - 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 engine = new Reversi.Game(map, { - isLlotheo: freshGame.isLlotheo, - canPutEverywhere: freshGame.canPutEverywhere, - loopedBoard: freshGame.loopedBoard, - }); - - if (engine.isEnded) { - let winner; - 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; - } - - 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), - }); - - 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), - }); + this.startGame(freshGame); }, 3000); } } + @bindThis + private async startGame(game: MiReversiGame) { + let bw: number; + if (game.bw === 'random') { + bw = Math.random() > 0.5 ? 1 : 2; + } else { + bw = parseInt(game.bw, 10); + } + + function getRandomMap() { + const mapCount = Object.entries(Reversi.maps).length; + const rnd = Math.floor(Math.random() * mapCount); + return Object.values(Reversi.maps)[rnd].data; + } + + const map = game.map != null ? game.map : getRandomMap(); + + const crc32 = CRC32.str(JSON.stringify(game.logs)).toString(); + + 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 engine = new Reversi.Game(map, { + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard, + }); + + if (engine.isEnded) { + let winner; + if (engine.winner === true) { + winner = bw === 1 ? game.user1Id : game.user2Id; + } else if (engine.winner === false) { + winner = bw === 1 ? game.user2Id : game.user1Id; + } else { + winner = null; + } + + 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), + }); + + 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), + }); + } + @bindThis public async getInvitations(user: MiUser): Promise { const invitations = await this.redisClient.zrange( @@ -510,6 +520,21 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } } + @bindThis + public async cancelGame(gameId: MiReversiGame['id'], user: MiUser) { + 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; + + await this.reversiGamesRepository.delete(game.id); + this.deleteGameCache(game.id); + + this.globalEventService.publishReversiGameStream(game.id, 'canceled', { + userId: user.id, + }); + } + @bindThis public async get(id: MiReversiGame['id']): Promise { const cached = await this.redisClient.get(`reversi:game:cache:${id}`); 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 77eaa6d1d3..df92137f51 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -40,6 +40,7 @@ class ReversiGameChannel extends Channel { switch (type) { case 'ready': this.ready(body); break; case 'updateSettings': this.updateSettings(body.key, body.value); break; + case 'cancel': this.cancelGame(); break; case 'putStone': this.putStone(body.pos, body.id); break; case 'checkState': this.checkState(body.crc32); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break; @@ -60,6 +61,13 @@ class ReversiGameChannel extends Channel { this.reversiService.gameReady(this.gameId!, this.user, ready); } + @bindThis + private async cancelGame() { + if (this.user == null) return; + + this.reversiService.cancelGame(this.gameId!, this.user); + } + @bindThis private async putStone(pos: number, id: string) { if (this.user == null) return; diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 360b75745c..9ca107278b 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -86,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts.cancel }} + {{ i18n.ts.cancel }} {{ i18n.ts._reversi.ready }} {{ i18n.ts._reversi.cancelReady }}
@@ -109,9 +109,12 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; import { MenuItem } from '@/types/menu.js'; +import { useRouter } from '@/global/router/supplier.js'; const $i = signinRequired(); +const router = useRouter(); + const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category))); const props = defineProps<{ @@ -171,8 +174,16 @@ function chooseMap(ev: MouseEvent) { os.popupMenu(menu, ev.currentTarget ?? ev.target); } -function exit() { - props.connection.send('exit', {}); +async function cancel() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + + props.connection.send('cancel', {}); + + router.push('/reversi'); } function ready() { diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index dbbeb20f42..0bdbfbcf54 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -17,6 +17,14 @@ import GameBoard from './game.board.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { useStream } from '@/stream.js'; +import { signinRequired } from '@/account.js'; +import { useRouter } from '@/global/router/supplier.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; + +const $i = signinRequired(); + +const router = useRouter(); const props = defineProps<{ gameId: string; @@ -45,6 +53,17 @@ async function fetchGame() { connection.value.on('started', x => { game.value = x.game; }); + connection.value.on('canceled', x => { + connection.value?.dispose(); + + if (x.userId !== $i.id) { + os.alert({ + type: 'warning', + text: i18n.ts._reversi.gameCanceled, + }); + router.push('/reversi'); + } + }); } onMounted(() => { From b3cc17ea0d53edca3b7f7a648ae94a591ec98d44 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 21 Jan 2024 17:08:36 +0900 Subject: [PATCH 05/35] enhance(reversi): tweak reversi --- packages/frontend/assets/reversi/lose.mp3 | Bin 0 -> 9565 bytes packages/frontend/assets/reversi/win.mp3 | Bin 0 -> 25703 bytes .../frontend/src/pages/reversi/game.board.vue | 124 ++++++++++------ .../src/pages/reversi/game.setting.vue | 133 ++++++++++-------- 4 files changed, 153 insertions(+), 104 deletions(-) create mode 100644 packages/frontend/assets/reversi/lose.mp3 create mode 100644 packages/frontend/assets/reversi/win.mp3 diff --git a/packages/frontend/assets/reversi/lose.mp3 b/packages/frontend/assets/reversi/lose.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b62d50baf778bfe2beaf879ad10cd46433c50164 GIT binary patch literal 9565 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