merge: upstream
This commit is contained in:
commit
913dd581ef
41 changed files with 817 additions and 201 deletions
16
packages/backend/migration/1706081514499-reversi-6.js
Normal file
16
packages/backend/migration/1706081514499-reversi-6.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class Reversi61706081514499 {
|
||||
name = 'Reversi61706081514499'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "reversi_game" ADD "noIrregularRules" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "reversi_game" DROP COLUMN "noIrregularRules"`);
|
||||
}
|
||||
}
|
|
@ -107,7 +107,6 @@
|
|||
"cli-highlight": "2.1.11",
|
||||
"color-convert": "2.0.1",
|
||||
"content-disposition": "0.5.4",
|
||||
"crc-32": "^1.2.2",
|
||||
"date-fns": "2.30.0",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"fastify-multer": "^2.0.3",
|
||||
|
|
|
@ -5,10 +5,9 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import CRC32 from 'crc-32';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import * as Reversi from 'misskey-reversi';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { IsNull, LessThan, MoreThan } from 'typeorm';
|
||||
import type {
|
||||
MiReversiGame,
|
||||
ReversiGamesRepository,
|
||||
|
@ -25,7 +24,7 @@ import { Serialized } from '@/types.js';
|
|||
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
|
||||
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec
|
||||
const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
|
||||
|
||||
@Injectable()
|
||||
export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
||||
|
@ -86,44 +85,82 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
map: game.map,
|
||||
bw: game.bw,
|
||||
crc32: game.crc32,
|
||||
noIrregularRules: game.noIrregularRules,
|
||||
} satisfies Partial<MiReversiGame>;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> {
|
||||
public async matchSpecificUser(me: MiUser, targetUser: MiUser, multiple = false): Promise<MiReversiGame | null> {
|
||||
if (targetUser.id === me.id) {
|
||||
throw new Error('You cannot match yourself.');
|
||||
}
|
||||
|
||||
if (!multiple) {
|
||||
// 既にマッチしている対局が無いか探す(3分以内)
|
||||
const games = await this.reversiGamesRepository.find({
|
||||
where: [
|
||||
{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: me.id, user2Id: targetUser.id, isStarted: false },
|
||||
{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: targetUser.id, user2Id: me.id, isStarted: false },
|
||||
],
|
||||
relations: ['user1', 'user2'],
|
||||
order: { id: 'DESC' },
|
||||
});
|
||||
if (games.length > 0) {
|
||||
return games[0];
|
||||
}
|
||||
}
|
||||
|
||||
//#region 相手から既に招待されてないか確認
|
||||
const invitations = await this.redisClient.zrange(
|
||||
`reversi:matchSpecific:${me.id}`,
|
||||
Date.now() - MATCHING_TIMEOUT_MS,
|
||||
Date.now() - INVITATION_TIMEOUT_MS,
|
||||
'+inf',
|
||||
'BYSCORE');
|
||||
|
||||
if (invitations.includes(targetUser.id)) {
|
||||
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
|
||||
|
||||
const game = await this.matched(targetUser.id, me.id);
|
||||
|
||||
return game;
|
||||
} else {
|
||||
this.redisClient.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
|
||||
|
||||
this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
|
||||
user: await this.userEntityService.pack(me, targetUser),
|
||||
const game = await this.matched(targetUser.id, me.id, {
|
||||
noIrregularRules: false,
|
||||
});
|
||||
|
||||
return null;
|
||||
return game;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
|
||||
redisPipeline.expire(`reversi:matchSpecific:${targetUser.id}`, 120, 'NX');
|
||||
await redisPipeline.exec();
|
||||
|
||||
this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
|
||||
user: await this.userEntityService.pack(me, targetUser),
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async matchAnyUser(me: MiUser): Promise<MiReversiGame | null> {
|
||||
public async matchAnyUser(me: MiUser, options: { noIrregularRules: boolean }, multiple = false): Promise<MiReversiGame | null> {
|
||||
if (!multiple) {
|
||||
// 既にマッチしている対局が無いか探す(3分以内)
|
||||
const games = await this.reversiGamesRepository.find({
|
||||
where: [
|
||||
{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user1Id: me.id, isStarted: false },
|
||||
{ id: MoreThan(this.idService.gen(Date.now() - 1000 * 60 * 3)), user2Id: me.id, isStarted: false },
|
||||
],
|
||||
relations: ['user1', 'user2'],
|
||||
order: { id: 'DESC' },
|
||||
});
|
||||
if (games.length > 0) {
|
||||
return games[0];
|
||||
}
|
||||
}
|
||||
|
||||
//#region まず自分宛ての招待を探す
|
||||
const invitations = await this.redisClient.zrange(
|
||||
`reversi:matchSpecific:${me.id}`,
|
||||
Date.now() - MATCHING_TIMEOUT_MS,
|
||||
Date.now() - INVITATION_TIMEOUT_MS,
|
||||
'+inf',
|
||||
'BYSCORE');
|
||||
|
||||
|
@ -131,7 +168,9 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
|
||||
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
|
||||
|
||||
const game = await this.matched(invitorId, me.id);
|
||||
const game = await this.matched(invitorId, me.id, {
|
||||
noIrregularRules: false,
|
||||
});
|
||||
|
||||
return game;
|
||||
}
|
||||
|
@ -139,23 +178,35 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
|
||||
const matchings = await this.redisClient.zrange(
|
||||
'reversi:matchAny',
|
||||
Date.now() - MATCHING_TIMEOUT_MS,
|
||||
'+inf',
|
||||
'BYSCORE');
|
||||
0,
|
||||
2, // 自分自身のIDが入っている場合もあるので2つ取得
|
||||
'REV');
|
||||
|
||||
const userIds = matchings.filter(id => id !== me.id);
|
||||
const items = matchings.filter(id => !id.startsWith(me.id));
|
||||
|
||||
if (userIds.length > 0) {
|
||||
// pick random
|
||||
const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)];
|
||||
if (items.length > 0) {
|
||||
const [matchedUserId, option] = items[0].split(':');
|
||||
|
||||
await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId);
|
||||
await this.redisClient.zrem('reversi:matchAny',
|
||||
me.id,
|
||||
matchedUserId,
|
||||
me.id + ':noIrregularRules',
|
||||
matchedUserId + ':noIrregularRules');
|
||||
|
||||
const game = await this.matched(matchedUserId, me.id);
|
||||
const game = await this.matched(matchedUserId, me.id, {
|
||||
noIrregularRules: options.noIrregularRules || option === 'noIrregularRules',
|
||||
});
|
||||
|
||||
return game;
|
||||
} else {
|
||||
await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id);
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
if (options.noIrregularRules) {
|
||||
redisPipeline.zadd('reversi:matchAny', Date.now(), me.id + ':noIrregularRules');
|
||||
} else {
|
||||
redisPipeline.zadd('reversi:matchAny', Date.now(), me.id);
|
||||
}
|
||||
redisPipeline.expire('reversi:matchAny', 15, 'NX');
|
||||
await redisPipeline.exec();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -167,7 +218,15 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async matchAnyUserCancel(user: MiUser) {
|
||||
await this.redisClient.zrem('reversi:matchAny', user.id);
|
||||
await this.redisClient.zrem('reversi:matchAny', user.id, user.id + ':noIrregularRules');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async cleanOutdatedGames() {
|
||||
await this.reversiGamesRepository.delete({
|
||||
id: LessThan(this.idService.gen(Date.now() - 1000 * 60 * 10)),
|
||||
isStarted: false,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -221,7 +280,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async matched(parentId: MiUser['id'], childId: MiUser['id']): Promise<MiReversiGame> {
|
||||
private async matched(parentId: MiUser['id'], childId: MiUser['id'], options: { noIrregularRules: boolean; }): Promise<MiReversiGame> {
|
||||
const game = await this.reversiGamesRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
user1Id: parentId,
|
||||
|
@ -234,6 +293,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
map: Reversi.maps.eighteight.data,
|
||||
bw: 'random',
|
||||
isLlotheo: false,
|
||||
noIrregularRules: options.noIrregularRules,
|
||||
}).then(x => this.reversiGamesRepository.findOneOrFail({
|
||||
where: { id: x.identifiers[0].id },
|
||||
relations: ['user1', 'user2'],
|
||||
|
@ -255,7 +315,13 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
bw = parseInt(game.bw, 10);
|
||||
}
|
||||
|
||||
const crc32 = CRC32.str(JSON.stringify(game.logs)).toString();
|
||||
const engine = new Reversi.Game(game.map, {
|
||||
isLlotheo: game.isLlotheo,
|
||||
canPutEverywhere: game.canPutEverywhere,
|
||||
loopedBoard: game.loopedBoard,
|
||||
});
|
||||
|
||||
const crc32 = engine.calcCrc32().toString();
|
||||
|
||||
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
|
@ -276,12 +342,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
this.cacheGame(updatedGame);
|
||||
|
||||
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
|
||||
const engine = new Reversi.Game(updatedGame.map, {
|
||||
isLlotheo: updatedGame.isLlotheo,
|
||||
canPutEverywhere: updatedGame.canPutEverywhere,
|
||||
loopedBoard: updatedGame.loopedBoard,
|
||||
});
|
||||
|
||||
if (engine.isEnded) {
|
||||
let winnerId;
|
||||
if (engine.winner === true) {
|
||||
|
@ -335,7 +395,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
|
||||
const invitations = await this.redisClient.zrange(
|
||||
`reversi:matchSpecific:${user.id}`,
|
||||
Date.now() - MATCHING_TIMEOUT_MS,
|
||||
Date.now() - INVITATION_TIMEOUT_MS,
|
||||
'+inf',
|
||||
'BYSCORE');
|
||||
return invitations;
|
||||
|
@ -406,7 +466,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
|
||||
const serializeLogs = Reversi.Serializer.serializeLogs(logs);
|
||||
|
||||
const crc32 = CRC32.str(JSON.stringify(serializeLogs)).toString();
|
||||
const crc32 = engine.calcCrc32().toString();
|
||||
|
||||
const updatedGame = {
|
||||
...game,
|
||||
|
@ -538,7 +598,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
if (game == null) throw new Error('game not found');
|
||||
|
||||
if (crc32.toString() !== game.crc32) {
|
||||
return await this.reversiGameEntityService.packDetail(game);
|
||||
return game;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ export class ReversiGameEntityService {
|
|||
canPutEverywhere: game.canPutEverywhere,
|
||||
loopedBoard: game.loopedBoard,
|
||||
timeLimitForEachTurn: game.timeLimitForEachTurn,
|
||||
noIrregularRules: game.noIrregularRules,
|
||||
logs: game.logs,
|
||||
map: game.map,
|
||||
});
|
||||
|
@ -105,6 +106,7 @@ export class ReversiGameEntityService {
|
|||
canPutEverywhere: game.canPutEverywhere,
|
||||
loopedBoard: game.loopedBoard,
|
||||
timeLimitForEachTurn: game.timeLimitForEachTurn,
|
||||
noIrregularRules: game.noIrregularRules,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -106,6 +106,11 @@ export class MiReversiGame {
|
|||
})
|
||||
public bw: string;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public noIrregularRules: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
|
@ -82,6 +82,10 @@ export const packedReversiGameLiteSchema = {
|
|||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
noIrregularRules: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isLlotheo: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
@ -196,6 +200,10 @@ export const packedReversiGameDetailedSchema = {
|
|||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
noIrregularRules: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isLlotheo: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
|
|
@ -11,6 +11,7 @@ import type Logger from '@/logger.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ReversiService } from '@/core/ReversiService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
|
@ -32,6 +33,7 @@ export class CleanProcessorService {
|
|||
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
||||
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
private reversiService: ReversiService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('clean');
|
||||
|
@ -65,6 +67,8 @@ export class CleanProcessorService {
|
|||
});
|
||||
}
|
||||
|
||||
this.reversiService.cleanOutdatedGames();
|
||||
|
||||
this.logger.succ('Cleaned.');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -385,6 +385,7 @@ import * as ep___reversi_match from './endpoints/reversi/match.js';
|
|||
import * as ep___reversi_invitations from './endpoints/reversi/invitations.js';
|
||||
import * as ep___reversi_showGame from './endpoints/reversi/show-game.js';
|
||||
import * as ep___reversi_surrender from './endpoints/reversi/surrender.js';
|
||||
import * as ep___reversi_verify from './endpoints/reversi/verify.js';
|
||||
import { GetterService } from './GetterService.js';
|
||||
import { ApiLoggerService } from './ApiLoggerService.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
@ -768,6 +769,7 @@ const $reversi_match: Provider = { provide: 'ep:reversi/match', useClass: ep___r
|
|||
const $reversi_invitations: Provider = { provide: 'ep:reversi/invitations', useClass: ep___reversi_invitations.default };
|
||||
const $reversi_showGame: Provider = { provide: 'ep:reversi/show-game', useClass: ep___reversi_showGame.default };
|
||||
const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass: ep___reversi_surrender.default };
|
||||
const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep___reversi_verify.default };
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
@ -1155,6 +1157,7 @@ const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass
|
|||
$reversi_invitations,
|
||||
$reversi_showGame,
|
||||
$reversi_surrender,
|
||||
$reversi_verify,
|
||||
],
|
||||
exports: [
|
||||
$admin_meta,
|
||||
|
@ -1533,6 +1536,7 @@ const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass
|
|||
$reversi_invitations,
|
||||
$reversi_showGame,
|
||||
$reversi_surrender,
|
||||
$reversi_verify,
|
||||
],
|
||||
})
|
||||
export class EndpointsModule {}
|
||||
|
|
|
@ -386,6 +386,7 @@ import * as ep___reversi_match from './endpoints/reversi/match.js';
|
|||
import * as ep___reversi_invitations from './endpoints/reversi/invitations.js';
|
||||
import * as ep___reversi_showGame from './endpoints/reversi/show-game.js';
|
||||
import * as ep___reversi_surrender from './endpoints/reversi/surrender.js';
|
||||
import * as ep___reversi_verify from './endpoints/reversi/verify.js';
|
||||
|
||||
const eps = [
|
||||
['admin/meta', ep___admin_meta],
|
||||
|
@ -767,6 +768,7 @@ const eps = [
|
|||
['reversi/invitations', ep___reversi_invitations],
|
||||
['reversi/show-game', ep___reversi_showGame],
|
||||
['reversi/surrender', ep___reversi_surrender],
|
||||
['reversi/verify', ep___reversi_verify],
|
||||
];
|
||||
|
||||
interface IEndpointMetaBase {
|
||||
|
|
|
@ -43,7 +43,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
|
||||
.andWhere('game.isStarted = TRUE')
|
||||
.innerJoinAndSelect('game.user1', 'user1')
|
||||
.innerJoinAndSelect('game.user2', 'user2');
|
||||
|
||||
|
@ -53,6 +52,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.where('game.user1Id = :userId', { userId: me.id })
|
||||
.orWhere('game.user2Id = :userId', { userId: me.id });
|
||||
}));
|
||||
} else {
|
||||
query.andWhere('game.isStarted = TRUE');
|
||||
}
|
||||
|
||||
const games = await query.take(ps.limit).getMany();
|
||||
|
|
|
@ -37,6 +37,8 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
noIrregularRules: { type: 'boolean', default: false },
|
||||
multiple: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -56,7 +58,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw err;
|
||||
}) : null;
|
||||
|
||||
const game = target ? await this.reversiService.matchSpecificUser(me, target) : await this.reversiService.matchAnyUser(me);
|
||||
const game = target
|
||||
? await this.reversiService.matchSpecificUser(me, target, ps.multiple)
|
||||
: await this.reversiService.matchAnyUser(me, { noIrregularRules: ps.noIrregularRules }, ps.multiple);
|
||||
|
||||
if (game == null) return;
|
||||
|
||||
|
|
64
packages/backend/src/server/api/endpoints/reversi/verify.ts
Normal file
64
packages/backend/src/server/api/endpoints/reversi/verify.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ReversiService } from '@/core/ReversiService.js';
|
||||
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
errors: {
|
||||
noSuchGame: {
|
||||
message: 'No such game.',
|
||||
code: 'NO_SUCH_GAME',
|
||||
id: '8fb05624-b525-43dd-90f7-511852bdfeee',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
desynced: { type: 'boolean' },
|
||||
game: {
|
||||
type: 'object',
|
||||
optional: true, nullable: true,
|
||||
ref: 'ReversiGameDetailed',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
gameId: { type: 'string', format: 'misskey:id' },
|
||||
crc32: { type: 'string' },
|
||||
},
|
||||
required: ['gameId', 'crc32'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private reversiService: ReversiService,
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const game = await this.reversiService.checkCrc(ps.gameId, ps.crc32);
|
||||
if (game) {
|
||||
return {
|
||||
desynced: true,
|
||||
game: await this.reversiGameEntityService.packDetail(game),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
desynced: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { MiReversiGame, ReversiGamesRepository } from '@/models/_.js';
|
||||
import type { MiReversiGame } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ReversiService } from '@/core/ReversiService.js';
|
||||
|
@ -19,7 +19,6 @@ class ReversiGameChannel extends Channel {
|
|||
|
||||
constructor(
|
||||
private reversiService: ReversiService,
|
||||
private reversiGamesRepository: ReversiGamesRepository,
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
|
||||
id: string,
|
||||
|
@ -42,7 +41,6 @@ class ReversiGameChannel extends Channel {
|
|||
case 'updateSettings': this.updateSettings(body.key, body.value); break;
|
||||
case 'cancel': this.cancelGame(); break;
|
||||
case 'putStone': this.putStone(body.pos, body.id); break;
|
||||
case 'resync': this.resync(body.crc32); break;
|
||||
case 'claimTimeIsUp': this.claimTimeIsUp(); break;
|
||||
}
|
||||
}
|
||||
|
@ -75,14 +73,6 @@ class ReversiGameChannel extends Channel {
|
|||
this.reversiService.putStoneToGame(this.gameId!, this.user, pos, id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async resync(crc32: string | number) {
|
||||
const game = await this.reversiService.checkCrc(this.gameId!, crc32);
|
||||
if (game) {
|
||||
this.send('resynced', game);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async claimTimeIsUp() {
|
||||
if (this.user == null) return;
|
||||
|
@ -104,9 +94,6 @@ export class ReversiGameChannelService implements MiChannelService<false> {
|
|||
public readonly kind = ReversiGameChannel.kind;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.reversiGamesRepository)
|
||||
private reversiGamesRepository: ReversiGamesRepository,
|
||||
|
||||
private reversiService: ReversiService,
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
) {
|
||||
|
@ -116,7 +103,6 @@ export class ReversiGameChannelService implements MiChannelService<false> {
|
|||
public create(id: string, connection: Channel['connection']): ReversiGameChannel {
|
||||
return new ReversiGameChannel(
|
||||
this.reversiService,
|
||||
this.reversiGamesRepository,
|
||||
this.reversiGameEntityService,
|
||||
id,
|
||||
connection,
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 181 KiB |
|
@ -42,10 +42,8 @@
|
|||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "10.3.1",
|
||||
"compare-versions": "6.1.0",
|
||||
"crc-32": "^1.2.2",
|
||||
"cropperjs": "2.0.0-beta.4",
|
||||
"date-fns": "2.30.0",
|
||||
"defu": "^6.1.4",
|
||||
"escape-regexp": "0.0.1",
|
||||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
|
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="[$style.transitionRoot]"
|
||||
:class="[$style.transitionRoot, { [$style.enableAnimation]: shouldAnimate }]"
|
||||
@touchstart.passive="touchStart"
|
||||
@touchmove.passive="touchMove"
|
||||
@touchend.passive="touchEnd"
|
||||
|
@ -44,6 +44,8 @@ const emit = defineEmits<{
|
|||
(ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
|
||||
}>();
|
||||
|
||||
const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value);
|
||||
|
||||
// ▼ しきい値 ▼ //
|
||||
|
||||
// スワイプと判定される最小の距離
|
||||
|
@ -188,7 +190,9 @@ watch(tabModel, (newTab, oldTab) => {
|
|||
.transitionChildren {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
transform: translateX(var(--swipe));
|
||||
}
|
||||
|
||||
.enableAnimation .transitionChildren {
|
||||
&.swipeAnimation_enterActive,
|
||||
&.swipeAnimation_leaveActive {
|
||||
transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
|
||||
|
|
|
@ -143,7 +143,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue';
|
||||
import * as CRC32 from 'crc-32';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as Reversi from 'misskey-reversi';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
@ -240,11 +239,17 @@ watch(logPos, (v) => {
|
|||
|
||||
if (game.value.isStarted && !game.value.isEnded) {
|
||||
useInterval(() => {
|
||||
if (game.value.isEnded || props.connection == null) return;
|
||||
const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString();
|
||||
if (game.value.isEnded) return;
|
||||
const crc32 = engine.value.calcCrc32();
|
||||
if (_DEV_) console.log('crc32', crc32);
|
||||
props.connection.send('resync', {
|
||||
crc32: crc32,
|
||||
misskeyApi('reversi/verify', {
|
||||
gameId: game.value.id,
|
||||
crc32: crc32.toString(),
|
||||
}).then((res) => {
|
||||
if (res.desynced) {
|
||||
console.log('resynced');
|
||||
restoreGame(res.game!);
|
||||
}
|
||||
});
|
||||
}, 10000, { immediate: false, afterMounted: true });
|
||||
}
|
||||
|
@ -392,12 +397,6 @@ function restoreGame(_game) {
|
|||
checkEnd();
|
||||
}
|
||||
|
||||
function onStreamResynced(_game) {
|
||||
console.log('resynced');
|
||||
|
||||
restoreGame(_game);
|
||||
}
|
||||
|
||||
async function surrender() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
|
@ -450,7 +449,6 @@ function share() {
|
|||
onMounted(() => {
|
||||
if (props.connection != null) {
|
||||
props.connection.on('log', onStreamLog);
|
||||
props.connection.on('resynced', onStreamResynced);
|
||||
props.connection.on('ended', onStreamEnded);
|
||||
}
|
||||
});
|
||||
|
@ -458,7 +456,6 @@ onMounted(() => {
|
|||
onActivated(() => {
|
||||
if (props.connection != null) {
|
||||
props.connection.on('log', onStreamLog);
|
||||
props.connection.on('resynced', onStreamResynced);
|
||||
props.connection.on('ended', onStreamEnded);
|
||||
}
|
||||
});
|
||||
|
@ -466,7 +463,6 @@ onActivated(() => {
|
|||
onDeactivated(() => {
|
||||
if (props.connection != null) {
|
||||
props.connection.off('log', onStreamLog);
|
||||
props.connection.off('resynced', onStreamResynced);
|
||||
props.connection.off('ended', onStreamEnded);
|
||||
}
|
||||
});
|
||||
|
@ -474,7 +470,6 @@ onDeactivated(() => {
|
|||
onUnmounted(() => {
|
||||
if (props.connection != null) {
|
||||
props.connection.off('log', onStreamLog);
|
||||
props.connection.off('resynced', onStreamResynced);
|
||||
props.connection.off('ended', onStreamEnded);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -12,85 +12,96 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="_gaps" :class="{ [$style.disallowInner]: isReady }">
|
||||
<div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div>
|
||||
|
||||
<div class="_panel">
|
||||
<div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
|
||||
<div>{{ mapName }}</div>
|
||||
<MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
|
||||
</div>
|
||||
<template v-if="game.noIrregularRules">
|
||||
<div>{{ i18n.ts._reversi.disallowIrregularRules }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="_panel">
|
||||
<div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);">
|
||||
<div>{{ mapName }}</div>
|
||||
<MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton>
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px;">
|
||||
<div v-if="game.map == null"><i class="ph-dice-five ph-bold ph-lg"></i></div>
|
||||
<div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
|
||||
<div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
|
||||
<i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ph-circle-half ph-bold ph-lg' : 'ph-circle ph-bold ph-lg'"></i>
|
||||
<div style="padding: 16px;">
|
||||
<div v-if="game.map == null"><i class="ph-dice-five ph-bold ph-lg"></i></div>
|
||||
<div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
|
||||
<div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)">
|
||||
<i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ph-circle-half ph-bold ph-lg' : 'ph-circle ph-bold ph-lg'"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts._reversi.blackOrWhite }}</template>
|
||||
|
||||
<MkRadios v-model="game.bw">
|
||||
<option value="random">{{ i18n.ts.random }}</option>
|
||||
<option :value="'1'">
|
||||
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
|
||||
<template #name>
|
||||
<b><MkUserName :user="game.user1"/></b>
|
||||
</template>
|
||||
</I18n>
|
||||
</option>
|
||||
<option :value="'2'">
|
||||
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
|
||||
<template #name>
|
||||
<b><MkUserName :user="game.user2"/></b>
|
||||
</template>
|
||||
</I18n>
|
||||
</option>
|
||||
</MkRadios>
|
||||
</MkFolder>
|
||||
<MkRadios v-model="game.bw">
|
||||
<option value="random">{{ i18n.ts.random }}</option>
|
||||
<option :value="'1'">
|
||||
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
|
||||
<template #name>
|
||||
<b><MkUserName :user="game.user1"/></b>
|
||||
</template>
|
||||
</I18n>
|
||||
</option>
|
||||
<option :value="'2'">
|
||||
<I18n :src="i18n.ts._reversi.blackIs" tag="span">
|
||||
<template #name>
|
||||
<b><MkUserName :user="game.user2"/></b>
|
||||
</template>
|
||||
</I18n>
|
||||
</option>
|
||||
</MkRadios>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template>
|
||||
<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template>
|
||||
<template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template>
|
||||
|
||||
<MkRadios v-model="game.timeLimitForEachTurn">
|
||||
<option :value="5">5{{ i18n.ts._time.second }}</option>
|
||||
<option :value="10">10{{ i18n.ts._time.second }}</option>
|
||||
<option :value="30">30{{ i18n.ts._time.second }}</option>
|
||||
<option :value="60">60{{ i18n.ts._time.second }}</option>
|
||||
<option :value="90">90{{ i18n.ts._time.second }}</option>
|
||||
<option :value="120">120{{ i18n.ts._time.second }}</option>
|
||||
<option :value="180">180{{ i18n.ts._time.second }}</option>
|
||||
<option :value="3600">3600{{ i18n.ts._time.second }}</option>
|
||||
</MkRadios>
|
||||
</MkFolder>
|
||||
<MkRadios v-model="game.timeLimitForEachTurn">
|
||||
<option :value="5">5{{ i18n.ts._time.second }}</option>
|
||||
<option :value="10">10{{ i18n.ts._time.second }}</option>
|
||||
<option :value="30">30{{ i18n.ts._time.second }}</option>
|
||||
<option :value="60">60{{ i18n.ts._time.second }}</option>
|
||||
<option :value="90">90{{ i18n.ts._time.second }}</option>
|
||||
<option :value="120">120{{ i18n.ts._time.second }}</option>
|
||||
<option :value="180">180{{ i18n.ts._time.second }}</option>
|
||||
<option :value="3600">3600{{ i18n.ts._time.second }}</option>
|
||||
</MkRadios>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts._reversi.rules }}</template>
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts._reversi.rules }}</template>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
|
||||
<MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
|
||||
<MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch>
|
||||
<MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch>
|
||||
<MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
|
||||
<div style="text-align: center; margin-bottom: 10px;">
|
||||
<template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
|
||||
<template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template>
|
||||
<template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template>
|
||||
<template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
|
||||
</div>
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded danger @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||
<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
|
||||
<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
|
||||
<div style="text-align: center;" class="_gaps_s">
|
||||
<div v-if="opponentHasSettingsChanged" style="color: var(--warn);">{{ i18n.ts._reversi.opponentHasSettingsChanged }}</div>
|
||||
<div>
|
||||
<template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
|
||||
<template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template>
|
||||
<template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template>
|
||||
<template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template>
|
||||
</div>
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded danger @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||
<MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton>
|
||||
<MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton>
|
||||
</div>
|
||||
<div>
|
||||
<MkSwitch v-model="shareWhenStart">{{ i18n.ts._reversi.shareToTlTheGameWhenStart }}</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
|
@ -124,6 +135,8 @@ const props = defineProps<{
|
|||
connection: Misskey.ChannelConnection;
|
||||
}>();
|
||||
|
||||
const shareWhenStart = defineModel<boolean>('shareWhenStart', { default: false });
|
||||
|
||||
const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game));
|
||||
|
||||
const mapName = computed(() => {
|
||||
|
@ -142,6 +155,8 @@ const isOpReady = computed(() => {
|
|||
return false;
|
||||
});
|
||||
|
||||
const opponentHasSettingsChanged = ref(false);
|
||||
|
||||
watch(() => game.value.bw, () => {
|
||||
updateSettings('bw');
|
||||
});
|
||||
|
@ -190,6 +205,7 @@ async function cancel() {
|
|||
|
||||
function ready() {
|
||||
props.connection.send('ready', true);
|
||||
opponentHasSettingsChanged.value = false;
|
||||
}
|
||||
|
||||
function unready() {
|
||||
|
@ -212,6 +228,10 @@ function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof M
|
|||
if (userId === $i.id) return;
|
||||
if (game.value[key] === value) return;
|
||||
game.value[key] = value;
|
||||
if (isReady.value) {
|
||||
opponentHasSettingsChanged.value = true;
|
||||
unready();
|
||||
}
|
||||
}
|
||||
|
||||
function onMapCellClick(pos: number, pixel: string) {
|
||||
|
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div v-if="game == null || (!game.isEnded && connection == null)"><MkLoading/></div>
|
||||
<GameSetting v-else-if="!game.isStarted" :game="game" :connection="connection!"/>
|
||||
<GameSetting v-else-if="!game.isStarted" v-model:shareWhenStart="shareWhenStart" :game="game" :connection="connection!"/>
|
||||
<GameBoard v-else :game="game" :connection="connection"/>
|
||||
</template>
|
||||
|
||||
|
@ -21,6 +21,7 @@ import { signinRequired } from '@/account.js';
|
|||
import { useRouter } from '@/global/router/supplier.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
|
@ -32,17 +33,32 @@ const props = defineProps<{
|
|||
|
||||
const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null);
|
||||
const connection = shallowRef<Misskey.ChannelConnection | null>(null);
|
||||
const shareWhenStart = ref(false);
|
||||
|
||||
watch(() => props.gameId, () => {
|
||||
fetchGame();
|
||||
});
|
||||
|
||||
function start(_game: Misskey.entities.ReversiGameDetailed) {
|
||||
if (game.value?.isStarted) return;
|
||||
|
||||
if (shareWhenStart.value) {
|
||||
misskeyApi('notes/create', {
|
||||
text: i18n.ts._reversi.iStartedAGame + '\n' + location.href,
|
||||
visibility: 'home',
|
||||
});
|
||||
}
|
||||
|
||||
game.value = _game;
|
||||
}
|
||||
|
||||
async function fetchGame() {
|
||||
const _game = await misskeyApi('reversi/show-game', {
|
||||
gameId: props.gameId,
|
||||
});
|
||||
|
||||
game.value = _game;
|
||||
shareWhenStart.value = false;
|
||||
|
||||
if (connection.value) {
|
||||
connection.value.dispose();
|
||||
|
@ -52,7 +68,7 @@ async function fetchGame() {
|
|||
gameId: game.value.id,
|
||||
});
|
||||
connection.value.on('started', x => {
|
||||
game.value = x.game;
|
||||
start(x.game);
|
||||
});
|
||||
connection.value.on('canceled', x => {
|
||||
connection.value?.dispose();
|
||||
|
@ -68,6 +84,25 @@ async function fetchGame() {
|
|||
}
|
||||
}
|
||||
|
||||
// 通信を取りこぼした場合の救済
|
||||
useInterval(async () => {
|
||||
if (game.value == null) return;
|
||||
if (game.value.isStarted) return;
|
||||
|
||||
const _game = await misskeyApi('reversi/show-game', {
|
||||
gameId: props.gameId,
|
||||
});
|
||||
|
||||
if (_game.isStarted) {
|
||||
start(_game);
|
||||
} else {
|
||||
game.value = _game;
|
||||
}
|
||||
}, 1000 * 10, {
|
||||
immediate: false,
|
||||
afterMounted: true,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchGame();
|
||||
});
|
||||
|
@ -78,10 +113,6 @@ onUnmounted(() => {
|
|||
}
|
||||
});
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: 'Reversi',
|
||||
icon: 'ph-game-controller ph-bold ph-lg',
|
||||
|
|
|
@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkPagination :pagination="myGamesPagination" :disableAutoLoad="true">
|
||||
<template #default="{ items }">
|
||||
<div :class="$style.gamePreviews">
|
||||
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
|
||||
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isStarted && !g.isEnded && $style.gamePreviewWaiting, g.isStarted && !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
|
||||
<div :class="$style.gamePreviewPlayers">
|
||||
<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
|
||||
<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
|
||||
|
@ -45,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
|
||||
</div>
|
||||
<div :class="$style.gamePreviewFooter">
|
||||
<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
|
||||
<span v-if="g.isStarted && !g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
|
||||
<span v-else-if="!g.isEnded" :class="$style.gamePreviewStatusWaiting"><MkEllipsis/></span>
|
||||
<span v-else>{{ i18n.ts._reversi.ended }}</span>
|
||||
<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
|
||||
</div>
|
||||
|
@ -60,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkPagination :pagination="gamesPagination" :disableAutoLoad="true">
|
||||
<template #default="{ items }">
|
||||
<div :class="$style.gamePreviews">
|
||||
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
|
||||
<MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isStarted && !g.isEnded && $style.gamePreviewWaiting, g.isStarted && !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`">
|
||||
<div :class="$style.gamePreviewPlayers">
|
||||
<span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
|
||||
<span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ti ti-x"></i></span>
|
||||
|
@ -71,7 +72,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ti ti-trophy"></i></span>
|
||||
</div>
|
||||
<div :class="$style.gamePreviewFooter">
|
||||
<span v-if="!g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
|
||||
<span v-if="g.isStarted && !g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span>
|
||||
<span v-else-if="!g.isEnded" :class="$style.gamePreviewStatusWaiting"><MkEllipsis/></span>
|
||||
<span v-else>{{ i18n.ts._reversi.ended }}</span>
|
||||
<MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/>
|
||||
</div>
|
||||
|
@ -137,7 +139,9 @@ if ($i) {
|
|||
const connection = useStream().useChannel('reversi');
|
||||
|
||||
connection.on('matched', x => {
|
||||
startGame(x.game);
|
||||
if (matchingUser.value != null || matchingAny.value) {
|
||||
startGame(x.game);
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('invited', invitation => {
|
||||
|
@ -153,6 +157,7 @@ if ($i) {
|
|||
const invitations = ref<Misskey.entities.UserLite[]>([]);
|
||||
const matchingUser = ref<Misskey.entities.UserLite | null>(null);
|
||||
const matchingAny = ref<boolean>(false);
|
||||
const noIrregularRules = ref<boolean>(false);
|
||||
|
||||
function startGame(game: Misskey.entities.ReversiGameDetailed) {
|
||||
matchingUser.value = null;
|
||||
|
@ -178,6 +183,7 @@ async function matchHeatbeat() {
|
|||
} else if (matchingAny.value) {
|
||||
const res = await misskeyApi('reversi/match', {
|
||||
userId: null,
|
||||
noIrregularRules: noIrregularRules.value,
|
||||
});
|
||||
|
||||
if (res != null) {
|
||||
|
@ -195,10 +201,22 @@ async function matchUser() {
|
|||
matchHeatbeat();
|
||||
}
|
||||
|
||||
async function matchAny() {
|
||||
matchingAny.value = true;
|
||||
|
||||
matchHeatbeat();
|
||||
function matchAny(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts._reversi.allowIrregularRules,
|
||||
action: () => {
|
||||
noIrregularRules.value = false;
|
||||
matchingAny.value = true;
|
||||
matchHeatbeat();
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._reversi.disallowIrregularRules,
|
||||
action: () => {
|
||||
noIrregularRules.value = true;
|
||||
matchingAny.value = true;
|
||||
matchHeatbeat();
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function cancelMatching() {
|
||||
|
@ -220,12 +238,14 @@ async function accept(user) {
|
|||
}
|
||||
}
|
||||
|
||||
useInterval(matchHeatbeat, 1000 * 10, { immediate: false, afterMounted: true });
|
||||
useInterval(matchHeatbeat, 1000 * 5, { immediate: false, afterMounted: true });
|
||||
|
||||
onMounted(() => {
|
||||
misskeyApi('reversi/invitations').then(_invitations => {
|
||||
invitations.value = _invitations;
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', cancelMatching);
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
|
@ -273,6 +293,10 @@ definePageMetadata(computed(() => ({
|
|||
box-shadow: inset 0 0 8px 0px var(--accent);
|
||||
}
|
||||
|
||||
.gamePreviewWaiting {
|
||||
box-shadow: inset 0 0 8px 0px var(--warn);
|
||||
}
|
||||
|
||||
.gamePreviewPlayers {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
|
@ -306,6 +330,12 @@ definePageMetadata(computed(() => ({
|
|||
animation: blink 2s infinite;
|
||||
}
|
||||
|
||||
.gamePreviewStatusWaiting {
|
||||
color: var(--warn);
|
||||
font-weight: bold;
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
|
||||
.waitingScreen {
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
import { onUnmounted, Ref, ref, watch } from 'vue';
|
||||
import { BroadcastChannel } from 'broadcast-channel';
|
||||
import { defu } from 'defu';
|
||||
import { $i } from '@/account.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { get, set } from '@/scripts/idb-proxy.js';
|
||||
|
@ -81,14 +80,37 @@ export class Storage<T extends StateDef> {
|
|||
this.loaded = this.ready.then(() => this.load());
|
||||
}
|
||||
|
||||
private isPureObject(value: unknown): value is Record<string, unknown> {
|
||||
private isPureObject(value: unknown): value is Record<string | number | symbol, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
private mergeState<T>(value: T, def: T): T {
|
||||
/**
|
||||
* valueにないキーをdefからもらう(再帰的)\
|
||||
* nullはそのまま、undefinedはdefの値
|
||||
**/
|
||||
private mergeObject<X>(value: X, def: X): X {
|
||||
if (this.isPureObject(value) && this.isPureObject(def)) {
|
||||
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def);
|
||||
return defu(value, def) as T;
|
||||
const result = structuredClone(value) as X;
|
||||
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
|
||||
result[k] = v;
|
||||
} else if (this.isPureObject(v) && this.isPureObject(result[k])) {
|
||||
const child = structuredClone(result[k]) as X[keyof X] & Record<string | number | symbol, unknown>;
|
||||
result[k] = this.mergeObject<typeof v>(child, v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private mergeState<X>(value: X, def: X): X {
|
||||
if (this.isPureObject(value) && this.isPureObject(def)) {
|
||||
const merged = this.mergeObject(value, def);
|
||||
|
||||
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);
|
||||
|
||||
return merged as X;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
|
31
packages/misskey-bubble-game/build.js
Normal file
31
packages/misskey-bubble-game/build.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { build } from "esbuild";
|
||||
import { globSync } from "glob";
|
||||
|
||||
const entryPoints = globSync("./src/**/**.{ts,tsx}");
|
||||
|
||||
/** @type {import('esbuild').BuildOptions} */
|
||||
const options = {
|
||||
entryPoints,
|
||||
minify: true,
|
||||
outdir: "./built/esm",
|
||||
target: "es2022",
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
};
|
||||
|
||||
if (process.env.WATCH === "true") {
|
||||
options.watch = {
|
||||
onRebuild(error, result) {
|
||||
if (error) {
|
||||
console.error("watch build failed:", error);
|
||||
} else {
|
||||
console.log("watch build succeeded:", result);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
build(options).catch((err) => {
|
||||
process.stderr.write(err.stderr);
|
||||
process.exit(1);
|
||||
});
|
|
@ -13,18 +13,21 @@
|
|||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run ts",
|
||||
"ts": "npm run ts-esm && npm run ts-dts",
|
||||
"ts-esm": "tsc --outDir built/esm",
|
||||
"ts-dts": "tsc --outDir built/dts --declaration true --emitDeclarationOnly true --declarationMap true",
|
||||
"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build\"",
|
||||
"build": "node ./build.js",
|
||||
"build:tsc": "npm run tsc",
|
||||
"tsc": "npm run ts-esm && npm run ts-dts",
|
||||
"tsc-esm": "tsc --outDir built/esm",
|
||||
"tsc-dts": "tsc --outDir built/dts --declaration true --emitDeclarationOnly true --declarationMap true",
|
||||
"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build:tsc\"",
|
||||
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@types/matter-js": "0.19.6",
|
||||
"@types/node": "20.11.5",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "6.18.1",
|
||||
"@typescript-eslint/parser": "6.18.1",
|
||||
"eslint": "8.56.0",
|
||||
|
@ -35,9 +38,9 @@
|
|||
"built"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/matter-js": "0.19.6",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"esbuild": "0.19.11",
|
||||
"eventemitter3": "5.0.1",
|
||||
"glob": "^10.3.10",
|
||||
"matter-js": "0.19.0",
|
||||
"seedrandom": "3.0.5"
|
||||
}
|
||||
|
|
|
@ -1633,6 +1633,8 @@ declare namespace entities {
|
|||
ReversiShowGameRequest,
|
||||
ReversiShowGameResponse,
|
||||
ReversiSurrenderRequest,
|
||||
ReversiVerifyRequest,
|
||||
ReversiVerifyResponse,
|
||||
Error_2 as Error,
|
||||
UserLite,
|
||||
UserDetailedNotMeOnly,
|
||||
|
@ -2644,6 +2646,12 @@ type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200
|
|||
// @public (undocumented)
|
||||
type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type ReversiVerifyRequest = operations['reversi/verify']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type ReversiVerifyResponse = operations['reversi/verify']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type Role = components['schemas']['Role'];
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2024.2.0-beta.2
|
||||
* generatedAt: 2024-01-22T07:11:08.412Z
|
||||
* version: 2024.2.0-beta.6
|
||||
* generatedAt: 2024-01-24T07:32:10.455Z
|
||||
*/
|
||||
|
||||
import type { SwitchCaseResponseType } from '../api.js';
|
||||
|
@ -4140,5 +4140,16 @@ declare module '../api.js' {
|
|||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *No*
|
||||
*/
|
||||
request<E extends 'reversi/verify', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2024.2.0-beta.2
|
||||
* generatedAt: 2024-01-22T07:11:08.410Z
|
||||
* version: 2024.2.0-beta.6
|
||||
* generatedAt: 2024-01-24T07:32:10.453Z
|
||||
*/
|
||||
|
||||
import type {
|
||||
|
@ -569,6 +569,8 @@ import type {
|
|||
ReversiShowGameRequest,
|
||||
ReversiShowGameResponse,
|
||||
ReversiSurrenderRequest,
|
||||
ReversiVerifyRequest,
|
||||
ReversiVerifyResponse,
|
||||
} from './entities.js';
|
||||
|
||||
export type Endpoints = {
|
||||
|
@ -951,4 +953,5 @@ export type Endpoints = {
|
|||
'reversi/invitations': { req: EmptyRequest; res: ReversiInvitationsResponse };
|
||||
'reversi/show-game': { req: ReversiShowGameRequest; res: ReversiShowGameResponse };
|
||||
'reversi/surrender': { req: ReversiSurrenderRequest; res: EmptyResponse };
|
||||
'reversi/verify': { req: ReversiVerifyRequest; res: ReversiVerifyResponse };
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2024.2.0-beta.2
|
||||
* generatedAt: 2024-01-22T07:11:08.408Z
|
||||
* version: 2024.2.0-beta.6
|
||||
* generatedAt: 2024-01-24T07:32:10.452Z
|
||||
*/
|
||||
|
||||
import { operations } from './types.js';
|
||||
|
@ -571,3 +571,5 @@ export type ReversiInvitationsResponse = operations['reversi/invitations']['resp
|
|||
export type ReversiShowGameRequest = operations['reversi/show-game']['requestBody']['content']['application/json'];
|
||||
export type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200']['content']['application/json'];
|
||||
export type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json'];
|
||||
export type ReversiVerifyRequest = operations['reversi/verify']['requestBody']['content']['application/json'];
|
||||
export type ReversiVerifyResponse = operations['reversi/verify']['responses']['200']['content']['application/json'];
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2024.2.0-beta.2
|
||||
* generatedAt: 2024-01-22T07:11:08.408Z
|
||||
* version: 2024.2.0-beta.6
|
||||
* generatedAt: 2024-01-24T07:32:10.450Z
|
||||
*/
|
||||
|
||||
import { components } from './types.js';
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
/* eslint @typescript-eslint/no-explicit-any: 0 */
|
||||
|
||||
/*
|
||||
* version: 2024.2.0-beta.2
|
||||
* generatedAt: 2024-01-22T07:11:08.327Z
|
||||
* version: 2024.2.0-beta.6
|
||||
* generatedAt: 2024-01-24T07:32:10.370Z
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -3645,6 +3645,15 @@ export type paths = {
|
|||
*/
|
||||
post: operations['reversi/surrender'];
|
||||
};
|
||||
'/reversi/verify': {
|
||||
/**
|
||||
* reversi/verify
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *No*
|
||||
*/
|
||||
post: operations['reversi/verify'];
|
||||
};
|
||||
};
|
||||
|
||||
export type webhooks = Record<string, never>;
|
||||
|
@ -4617,6 +4626,7 @@ export type components = {
|
|||
timeoutUserId: string | null;
|
||||
black: number | null;
|
||||
bw: string;
|
||||
noIrregularRules: boolean;
|
||||
isLlotheo: boolean;
|
||||
canPutEverywhere: boolean;
|
||||
loopedBoard: boolean;
|
||||
|
@ -4652,6 +4662,7 @@ export type components = {
|
|||
timeoutUserId: string | null;
|
||||
black: number | null;
|
||||
bw: string;
|
||||
noIrregularRules: boolean;
|
||||
isLlotheo: boolean;
|
||||
canPutEverywhere: boolean;
|
||||
loopedBoard: boolean;
|
||||
|
@ -26677,6 +26688,10 @@ export type operations = {
|
|||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
userId?: string | null;
|
||||
/** @default false */
|
||||
noIrregularRules?: boolean;
|
||||
/** @default false */
|
||||
multiple?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -26871,5 +26886,63 @@ export type operations = {
|
|||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* reversi/verify
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *No*
|
||||
*/
|
||||
'reversi/verify': {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
gameId: string;
|
||||
crc32: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
desynced: boolean;
|
||||
game?: components['schemas']['ReversiGameDetailed'] | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
|
|
31
packages/misskey-reversi/build.js
Normal file
31
packages/misskey-reversi/build.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { build } from "esbuild";
|
||||
import { globSync } from "glob";
|
||||
|
||||
const entryPoints = globSync("./src/**/**.{ts,tsx}");
|
||||
|
||||
/** @type {import('esbuild').BuildOptions} */
|
||||
const options = {
|
||||
entryPoints,
|
||||
minify: true,
|
||||
outdir: "./built/esm",
|
||||
target: "es2022",
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
};
|
||||
|
||||
if (process.env.WATCH === "true") {
|
||||
options.watch = {
|
||||
onRebuild(error, result) {
|
||||
if (error) {
|
||||
console.error("watch build failed:", error);
|
||||
} else {
|
||||
console.log("watch build succeeded:", result);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
build(options).catch((err) => {
|
||||
process.stderr.write(err.stderr);
|
||||
process.exit(1);
|
||||
});
|
|
@ -13,11 +13,12 @@
|
|||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run ts",
|
||||
"ts": "npm run ts-esm && npm run ts-dts",
|
||||
"ts-esm": "tsc --outDir built/esm",
|
||||
"ts-dts": "tsc --outDir built/dts --declaration true --emitDeclarationOnly true --declarationMap true",
|
||||
"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build\"",
|
||||
"build": "node ./build.js",
|
||||
"build:tsc": "npm run tsc",
|
||||
"tsc": "npm run tsc-esm && npm run tsc-dts",
|
||||
"tsc-esm": "tsc --outDir built/esm",
|
||||
"tsc-dts": "tsc --outDir built/dts --declaration true --emitDeclarationOnly true --declarationMap true",
|
||||
"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build:tsc\"",
|
||||
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
|
@ -35,5 +36,8 @@
|
|||
"built"
|
||||
],
|
||||
"dependencies": {
|
||||
"crc-32": "1.2.2",
|
||||
"esbuild": "0.19.11",
|
||||
"glob": "10.3.10"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import CRC32 from 'crc-32';
|
||||
|
||||
/**
|
||||
* true ... 黒
|
||||
* false ... 白
|
||||
|
@ -204,6 +206,13 @@ export class Game {
|
|||
return ([] as number[]).concat(...diffVectors.map(effectsInLine));
|
||||
}
|
||||
|
||||
public calcCrc32(): number {
|
||||
return CRC32.str(JSON.stringify({
|
||||
board: this.board,
|
||||
turn: this.turn,
|
||||
}));
|
||||
}
|
||||
|
||||
public get isEnded(): boolean {
|
||||
return this.turn === null;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue