diff --git a/locales/index.d.ts b/locales/index.d.ts index b7d4c74f33..06e52717d1 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2638,7 +2638,7 @@ export interface Locale extends ILocale { "gameSettings": string; "chooseBoard": string; "blackOrWhite": string; - "blackIs": string; + "blackIs": ParameterizedString<"name">; "rules": string; "thisGameIsStartedSoon": string; "waitingForOther": string; @@ -2648,16 +2648,16 @@ export interface Locale extends ILocale { "cancelReady": string; "opponentTurn": string; "myTurn": string; - "turnOf": string; - "pastTurnOf": string; + "turnOf": ParameterizedString<"name">; + "pastTurnOf": ParameterizedString<"name">; "surrender": string; "surrendered": string; "drawn": string; - "won": string; + "won": ParameterizedString<"name">; "black": string; "white": string; "total": string; - "turnCount": string; + "turnCount": ParameterizedString<"count">; "myGames": string; "allGames": string; "ended": string; diff --git a/packages/backend/migration/1705654039457-reversi-2.js b/packages/backend/migration/1705654039457-reversi-2.js new file mode 100644 index 0000000000..33747ba9f7 --- /dev/null +++ b/packages/backend/migration/1705654039457-reversi-2.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Reversi21705654039457 { + name = 'Reversi21705654039457' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user1Accepted" TO "user1Ready"`); + await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user2Accepted" TO "user2Ready"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user1Ready" TO "user1Accepted"`); + await queryRunner.query(`ALTER TABLE "reversi_game" RENAME COLUMN "user2Ready" TO "user2Accepted"`); + } +} diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index aa812c7a99..96fae46729 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -170,9 +170,7 @@ export interface ReversiEventTypes { } export interface ReversiGameEventTypes { - accept: boolean; - cancelAccept: undefined; - changeAcceptingStates: { + changeReadyStates: { user1: boolean; user2: boolean; }; @@ -181,7 +179,7 @@ export interface ReversiGameEventTypes { value: any; }; putStone: { - at: Date; + at: number; color: boolean; pos: number; next: boolean; diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index f5383af058..b030d6896e 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import * as CRC32 from 'crc-32'; +import CRC32 from 'crc-32'; import { ModuleRef } from '@nestjs/core'; import * as Reversi from 'misskey-reversi'; import { IsNull } from 'typeorm'; @@ -28,7 +28,7 @@ import { NotificationService } from '@/core/NotificationService.js'; import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; -const MATCHING_TIMEOUT_MS = 15 * 1000; // 15sec +const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec @Injectable() export class ReversiService implements OnApplicationShutdown, OnModuleInit { @@ -61,7 +61,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { throw new Error('You cannot match yourself.'); } - const invitations = await this.redisClient.zrange(`reversi:matchSpecific:${me.id}`, Date.now() - MATCHING_TIMEOUT_MS, '+inf'); + const invitations = await this.redisClient.zrange( + `reversi:matchSpecific:${me.id}`, + Date.now() - MATCHING_TIMEOUT_MS, + '+inf', + 'BYSCORE'); if (invitations.includes(targetUser.id)) { await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id); @@ -70,8 +74,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { id: this.idService.gen(), user1Id: targetUser.id, user2Id: me.id, - user1Accepted: false, - user2Accepted: false, + user1Ready: false, + user2Ready: false, isStarted: false, isEnded: false, logs: [], @@ -97,21 +101,26 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { @bindThis public async matchAnyUser(me: MiUser): Promise { - const scanRes = await this.redisClient.scan(0, 'MATCH', 'reversi:matchAny:*', 'COUNT', 10); - const userIds = scanRes[1].map(key => key.split(':')[2]).filter(id => id !== me.id); + const matchings = await this.redisClient.zrange( + 'reversi:matchAny', + Date.now() - MATCHING_TIMEOUT_MS, + '+inf', + 'BYSCORE'); + + const userIds = matchings.filter(id => id !== me.id); if (userIds.length > 0) { // pick random const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)]; - await this.redisClient.del(`reversi:matchAny:${matchedUserId}`); + await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId); const game = await this.reversiGamesRepository.insert({ id: this.idService.gen(), user1Id: matchedUserId, user2Id: me.id, - user1Accepted: false, - user2Accepted: false, + user1Ready: false, + user2Ready: false, isStarted: false, isEnded: false, logs: [], @@ -125,7 +134,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { return game; } else { - await this.redisClient.setex(`reversi:matchAny:${me.id}`, MATCHING_TIMEOUT_MS / 1000, ''); + await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id); return null; } } @@ -137,47 +146,47 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { @bindThis public async matchAnyUserCancel(user: MiUser) { - await this.redisClient.del(`reversi:matchAny:${user.id}`); + await this.redisClient.zrem('reversi:matchAny', user.id); } @bindThis - public async matchAccept(game: MiReversiGame, user: MiUser, isAccepted: boolean) { + public async gameReady(game: MiReversiGame, user: MiUser, ready: boolean) { if (game.isStarted) return; - let bothAccepted = false; + let isBothReady = false; if (game.user1Id === user.id) { await this.reversiGamesRepository.update(game.id, { - user1Accepted: isAccepted, + user1Ready: ready, }); - this.globalEventService.publishReversiGameStream(game.id, 'changeAcceptingStates', { - user1: isAccepted, - user2: game.user2Accepted, + this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', { + user1: ready, + user2: game.user2Ready, }); - if (isAccepted && game.user2Accepted) bothAccepted = true; + if (ready && game.user2Ready) isBothReady = true; } else if (game.user2Id === user.id) { await this.reversiGamesRepository.update(game.id, { - user2Accepted: isAccepted, + user2Ready: ready, }); - this.globalEventService.publishReversiGameStream(game.id, 'changeAcceptingStates', { - user1: game.user1Accepted, - user2: isAccepted, + this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', { + user1: game.user1Ready, + user2: ready, }); - if (isAccepted && game.user1Accepted) bothAccepted = true; + if (ready && game.user1Ready) isBothReady = true; } else { return; } - if (bothAccepted) { - // 3秒後、まだacceptされていたらゲーム開始 + if (isBothReady) { + // 3秒後、両者readyならゲーム開始 setTimeout(async () => { const freshGame = await this.reversiGamesRepository.findOneBy({ id: game.id }); if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; - if (!freshGame.user1Accepted || !freshGame.user2Accepted) return; + if (!freshGame.user1Ready || !freshGame.user2Ready) return; let bw: number; if (freshGame.bw === 'random') { @@ -239,7 +248,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { @bindThis public async getInvitations(user: MiUser): Promise { - const invitations = await this.redisClient.zrange(`reversi:matchSpecific:${user.id}`, Date.now() - MATCHING_TIMEOUT_MS, '+inf'); + const invitations = await this.redisClient.zrange( + `reversi:matchSpecific:${user.id}`, + Date.now() - MATCHING_TIMEOUT_MS, + '+inf', + 'BYSCORE'); return invitations; } @@ -247,8 +260,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { public async updateSettings(game: MiReversiGame, user: MiUser, key: string, value: any) { if (game.isStarted) return; if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return; - if ((game.user1Id === user.id) && game.user1Accepted) return; - if ((game.user2Id === user.id) && game.user2Accepted) 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; @@ -301,7 +314,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } const log = { - at: new Date(), + at: Date.now(), color: myColor, pos, }; @@ -317,9 +330,10 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { logs: game.logs, }); - this.globalEventService.publishReversiGameStream(game.id, 'putStone', Object.assign(log, { + this.globalEventService.publishReversiGameStream(game.id, 'putStone', { + ...log, next: o.turn, - })); + }); if (o.isEnded) { this.globalEventService.publishReversiGameStream(game.id, 'ended', { diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index cdbf0ffdc3..8d95204928 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -41,8 +41,8 @@ export class ReversiGameEntityService { isEnded: game.isEnded, form1: game.form1, form2: game.form2, - user1Accepted: game.user1Accepted, - user2Accepted: game.user2Accepted, + user1Ready: game.user1Ready, + user2Ready: game.user2Ready, user1Id: game.user1Id, user2Id: game.user2Id, user1: this.userEntityService.pack(game.user1Id, me), @@ -56,7 +56,7 @@ export class ReversiGameEntityService { canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, logs: game.logs.map(log => ({ - at: log.at.toISOString(), + at: log.at, color: log.color, pos: log.pos, })), @@ -87,8 +87,8 @@ export class ReversiGameEntityService { isEnded: game.isEnded, form1: game.form1, form2: game.form2, - user1Accepted: game.user1Accepted, - user2Accepted: game.user2Accepted, + user1Ready: game.user1Ready, + user2Ready: game.user2Ready, user1Id: game.user1Id, user2Id: game.user2Id, user1: this.userEntityService.pack(game.user1Id, me), diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts index d8c9f00132..d297d1f01d 100644 --- a/packages/backend/src/models/ReversiGame.ts +++ b/packages/backend/src/models/ReversiGame.ts @@ -34,12 +34,12 @@ export class MiReversiGame { @Column('boolean', { default: false, }) - public user1Accepted: boolean; + public user1Ready: boolean; @Column('boolean', { default: false, }) - public user2Accepted: boolean; + public user2Ready: boolean; /** * どちらのプレイヤーが先行(黒)か @@ -77,7 +77,7 @@ export class MiReversiGame { default: [], }) public logs: { - at: Date; + at: number; color: boolean; pos: number; }[]; diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts index 7ffb447db5..0d23b9dc79 100644 --- a/packages/backend/src/models/json-schema/reversi-game.ts +++ b/packages/backend/src/models/json-schema/reversi-game.ts @@ -37,11 +37,11 @@ export const packedReversiGameLiteSchema = { type: 'any', optional: false, nullable: true, }, - user1Accepted: { + user1Ready: { type: 'boolean', optional: false, nullable: false, }, - user2Accepted: { + user2Ready: { type: 'boolean', optional: false, nullable: false, }, @@ -137,11 +137,11 @@ export const packedReversiGameDetailedSchema = { type: 'any', optional: false, nullable: true, }, - user1Accepted: { + user1Ready: { type: 'boolean', optional: false, nullable: false, }, - user2Accepted: { + user2Ready: { type: 'boolean', optional: false, nullable: false, }, @@ -208,9 +208,8 @@ export const packedReversiGameDetailedSchema = { optional: false, nullable: false, properties: { at: { - type: 'string', + type: 'number', optional: false, nullable: false, - format: 'date-time', }, color: { type: 'boolean', diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index fa81380f01..aed352d15e 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -22,9 +22,13 @@ import { SigninApiService } from './api/SigninApiService.js'; import { SigninService } from './api/SigninService.js'; import { SignupApiService } from './api/SignupApiService.js'; import { StreamingApiServerService } from './api/StreamingApiServerService.js'; +import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { FeedService } from './web/FeedService.js'; import { UrlPreviewService } from './web/UrlPreviewService.js'; +import { ClientLoggerService } from './web/ClientLoggerService.js'; +import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; + import { MainChannelService } from './api/stream/channels/main.js'; import { AdminChannelService } from './api/stream/channels/admin.js'; import { AntennaChannelService } from './api/stream/channels/antenna.js'; @@ -38,10 +42,9 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; -import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; -import { ClientLoggerService } from './web/ClientLoggerService.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; -import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; +import { ReversiChannelService } from './api/stream/channels/reversi.js'; +import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; @Module({ imports: [ @@ -77,6 +80,8 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; GlobalTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, + ReversiChannelService, + ReversiGameChannelService, HomeTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, 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 1e5b43783e..c67c05fb09 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -43,8 +43,7 @@ class ReversiGameChannel extends Channel { @bindThis public onMessage(type: string, body: any) { switch (type) { - case 'accept': this.accept(true); break; - case 'cancelAccept': this.accept(false); break; + case 'ready': this.ready(body); break; case 'updateSettings': this.updateSettings(body.key, body.value); break; case 'putStone': this.putStone(body.pos); break; case 'syncState': this.syncState(body.crc32); break; @@ -63,13 +62,13 @@ class ReversiGameChannel extends Channel { } @bindThis - private async accept(accept: boolean) { + 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.matchAccept(game, this.user, accept); + this.reversiService.gameReady(game, this.user, ready); } @bindThis diff --git a/packages/frontend/assets/reversi/logo.png b/packages/frontend/assets/reversi/logo.png new file mode 100644 index 0000000000..7d807ef1dc Binary files /dev/null and b/packages/frontend/assets/reversi/logo.png differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 2cf13d9cff..a9a68601fc 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -41,6 +41,7 @@ "chartjs-plugin-zoom": "2.0.1", "chromatic": "10.1.0", "compare-versions": "6.1.0", + "crc-32": "^1.2.2", "cropperjs": "2.0.0-beta.4", "date-fns": "2.30.0", "escape-regexp": "0.0.1", diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 33b8a9a86d..16416fd2e4 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -52,7 +52,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'changeByUser'): void; (ev: 'update:modelValue', value: string | null): void; }>(); @@ -77,7 +77,6 @@ const height = const focus = () => inputEl.value.focus(); const onInput = (ev) => { changed.value = true; - emit('change', ev); }; const updated = () => { @@ -136,6 +135,7 @@ function show(ev: MouseEvent) { active: computed(() => v.value === option.props.value), action: () => { v.value = option.props.value; + emit('changeByUser', v.value); }, }); }; diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index f4aa06950d..ad11ba1940 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -85,7 +85,7 @@ const recentUsers = ref([]); const selected = ref(null); const dialogEl = ref(); -const search = () => { +function search() { if (username.value === '' && host.value === '') { users.value = []; return; @@ -98,9 +98,9 @@ const search = () => { }).then(_users => { users.value = _users; }); -}; +} -const ok = () => { +function ok() { if (selected.value == null) return; emit('ok', selected.value); dialogEl.value.close(); @@ -110,12 +110,12 @@ const ok = () => { recents = recents.filter(x => x !== selected.value.id); recents.unshift(selected.value.id); defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); -}; +} -const cancel = () => { +function cancel() { emit('cancel'); dialogEl.value.close(); -}; +} onMounted(() => { misskeyApi('users/show', { diff --git a/packages/frontend/src/global/router/definition.ts b/packages/frontend/src/global/router/definition.ts index c8448ce198..819cce6d16 100644 --- a/packages/frontend/src/global/router/definition.ts +++ b/packages/frontend/src/global/router/definition.ts @@ -15,6 +15,7 @@ const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ loadingComponent: MkLoading, errorComponent: MkError, }); + const routes = [{ path: '/@:initUser/pages/:initPageName/view-source', component: page(() => import('@/pages/page-editor/page-editor.vue')), @@ -523,18 +524,26 @@ const routes = [{ path: '/timeline/antenna/:antennaId', component: page(() => import('@/pages/antenna-timeline.vue')), loginRequired: true, -}, { - path: '/games', - component: page(() => import('@/pages/games.vue')), - loginRequired: true, }, { path: '/clicker', component: page(() => import('@/pages/clicker.vue')), loginRequired: true, +}, { + path: '/games', + component: page(() => import('@/pages/games.vue')), + loginRequired: false, }, { path: '/bubble-game', component: page(() => import('@/pages/drop-and-fusion.vue')), loginRequired: true, +}, { + path: '/reversi', + component: page(() => import('@/pages/reversi/index.vue')), + loginRequired: false, +}, { + path: '/reversi/g/:gameId', + component: page(() => import('@/pages/reversi/game.vue')), + loginRequired: false, }, { path: '/timeline', component: page(() => import('@/pages/timeline.vue')), diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index a63d61bb8f..9fc3603af0 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -419,7 +419,7 @@ export function form(title, form) { }); } -export async function selectUser(opts: { includeSelf?: boolean } = {}) { +export async function selectUser(opts: { includeSelf?: boolean } = {}): Promise { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), { includeSelf: opts.includeSelf, diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue index 5d2482ded1..45a135a459 100644 --- a/packages/frontend/src/pages/games.vue +++ b/packages/frontend/src/pages/games.vue @@ -7,10 +7,17 @@ SPDX-License-Identifier: AGPL-3.0-only -
- - - +
+
+ + + +
+
+ + + +
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index b69d94e9c2..71bad546a4 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -4,490 +4,430 @@ SPDX-License-Identifier: AGPL-3.0-only --> - + + diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 57cb8d907e..d118205b99 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -4,86 +4,82 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index 37d22ae357..dbbeb20f42 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only