diff --git a/CHANGELOG.md b/CHANGELOG.md index c9df810efd..8c45a13c9c 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で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 @@ -57,6 +58,9 @@ - Fix: ipv4とipv6の両方が利用可能な環境でallowedPrivateNetworksが設定されていた場合プライベートipの検証ができていなかった問題を修正 - Fix: properly handle cc followers +### Service Worker +- Enhance: オフライン表示のデザインを改善・多言語対応 + ## 2023.12.2 ### General diff --git a/Dockerfile b/Dockerfile index 922ce4dca3..a8d3dbcd89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,8 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/frontend/package.json", "./packages/frontend/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] +COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] +COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"] RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output @@ -52,6 +54,8 @@ COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] COPY --link ["scripts", "./scripts"] COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] +COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] +COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"] RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output @@ -79,8 +83,12 @@ WORKDIR /misskey COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules +COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-reversi/node_modules ./packages/misskey-reversi/node_modules +COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-bubble-game/node_modules ./packages/misskey-bubble-game/node_modules COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/built ./packages/misskey-js/built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ diff --git a/locales/index.d.ts b/locales/index.d.ts index 54b370bd0d..24ccdca86a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -580,6 +580,10 @@ export interface Locale extends ILocale { * 添付取り消し */ "attachCancel": string; + /** + * ファイルを削除 + */ + "deleteFile": string; /** * センシティブとして設定 */ @@ -9777,6 +9781,10 @@ export interface Locale extends ILocale { * 投了により */ "surrendered": string; + /** + * 時間切れ + */ + "timeout": string; /** * 引き分け */ @@ -9829,6 +9837,10 @@ export interface Locale extends ILocale { * どこでも置けるモード */ "canPutEverywhere": string; + /** + * 1ターンの時間制限 + */ + "timeLimitForEachTurn": string; /** * フリーマッチ */ @@ -9837,6 +9849,20 @@ export interface Locale extends ILocale { * 対戦相手を探しています */ "lookingForPlayer": string; + /** + * 対局がキャンセルされました + */ + "gameCanceled": string; + }; + "_offlineScreen": { + /** + * オフライン - サーバーに接続できません + */ + "title": string; + /** + * サーバーに接続できません + */ + "header": string; }; } declare const locales: { diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2080e1992e..af2e9738bc 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -141,6 +141,7 @@ overwriteFromPinnedEmojis: "全般設定から上書きする" reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。" rememberNoteVisibility: "公開範囲を記憶する" attachCancel: "添付取り消し" +deleteFile: "ファイルを削除" markAsSensitive: "センシティブとして設定" unmarkAsSensitive: "センシティブを解除する" enterFileName: "ファイル名を入力" @@ -2603,6 +2604,7 @@ _reversi: pastTurnOf: "{name}のターン" surrender: "投了" surrendered: "投了により" + timeout: "時間切れ" drawn: "引き分け" won: "{name}の勝ち" black: "黒" @@ -2616,5 +2618,11 @@ _reversi: isLlotheo: "石の少ない方が勝ち(ロセオ)" loopedMap: "ループマップ" canPutEverywhere: "どこでも置けるモード" + timeLimitForEachTurn: "1ターンの時間制限" freeMatch: "フリーマッチ" lookingForPlayer: "対戦相手を探しています" + gameCanceled: "対局がキャンセルされました" + +_offlineScreen: + title: "オフライン - サーバーに接続できません" + header: "サーバーに接続できません" 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 896149f238..5b4c8cb44f 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 }; - syncState: { - crc32: string; - }; started: { game: Packed<'ReversiGameDetailed'>; }; @@ -191,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 9fe7255e48..f97f71eb43 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,16 @@ 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 + 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) { @@ -83,6 +94,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 +137,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 +173,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 +196,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; } @@ -216,71 +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(); - - await this.reversiGamesRepository.update(game.id, { - startedAt: new Date(), - isStarted: true, - black: bw, - map: map, - crc32, - }); - - //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 - const o = new Reversi.Game(map, { - isLlotheo: freshGame.isLlotheo, - canPutEverywhere: freshGame.canPutEverywhere, - loopedBoard: freshGame.loopedBoard, - }); - - if (o.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; - } else { - winner = null; - } - - await this.reversiGamesRepository.update(game.id, { - isEnded: true, - winnerId: winner, - }); - - this.globalEventService.publishReversiGameStream(game.id, 'ended', { - winnerId: winner, - game: await this.reversiGameEntityService.packDetail(game.id, user), - }); - } - //#endregion - - this.globalEventService.publishReversiGameStream(game.id, 'started', { - game: await this.reversiGameEntityService.packDetail(game.id, user), - }); + 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( @@ -292,17 +342,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 +372,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 +423,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,33 +444,127 @@ 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 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}`); + 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 2d8c396db9..df92137f51 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); } @@ -45,8 +40,10 @@ 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 'syncState': this.syncState(body.crc32); break; + case 'checkState': this.checkState(body.crc32); break; + case 'claimTimeIsUp': this.claimTimeIsUp(); break; } } @@ -54,47 +51,47 @@ 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(this.gameId!, this.user, ready); + } - this.reversiService.gameReady(game, 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; - // 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 syncState(crc32: string | number) { - // 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.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); } } + @bindThis + private async claimTimeIsUp() { + if (this.user == null) return; + + this.reversiService.checkTimeout(this.gameId!); + } + @bindThis public dispose() { // Unsubscribe events diff --git a/packages/frontend/assets/reversi/matched.mp3 b/packages/frontend/assets/reversi/matched.mp3 new file mode 100644 index 0000000000..f26d07614e Binary files /dev/null and b/packages/frontend/assets/reversi/matched.mp3 differ 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; } diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index fbe44d0161..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._reversi.myTurn }}
-
+
{{ i18n.ts._reversi.opponentTurn }}({{ i18n.tsx.remainingN({ n: opTurnTimerRmain }) }})
+
{{ i18n.ts._reversi.myTurn }}({{ i18n.tsx.remainingN({ n: myTurnTimerRmain }) }})
+
@@ -44,6 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- - + + + +
@@ -74,6 +84,17 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
{{ logPos }} / {{ game.logs.length }}
+
+ + + + +
+ +
+
{{ i18n.tsx._reversi.turnCount({ count: logPos }) }} {{ i18n.ts._reversi.black }}:{{ engine.blackCount }} {{ i18n.ts._reversi.white }}:{{ engine.whiteCount }} {{ i18n.ts._reversi.total }}:{{ engine.blackCount + engine.whiteCount }} @@ -111,17 +132,6 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.share }}
-
-
{{ logPos }} / {{ game.logs.length }}
-
- - - - -
- -
- @@ -130,7 +140,7 @@ SPDX-License-Identifier: AGPL-3.0-only `; } 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',