From a17251d913c822e3113b47ed8135eecb3f06c445 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 21 Jan 2024 10:07:43 +0900 Subject: [PATCH] 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[]; };