enhance(reversi): improve desync handling

This commit is contained in:
syuilo 2024-01-23 10:51:59 +09:00
parent f48f7149f8
commit e8ba0b3f54
17 changed files with 206 additions and 60 deletions

View file

@ -107,7 +107,6 @@
"cli-highlight": "2.1.11", "cli-highlight": "2.1.11",
"color-convert": "2.0.1", "color-convert": "2.0.1",
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"crc-32": "^1.2.2",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"fastify": "4.25.2", "fastify": "4.25.2",

View file

@ -5,7 +5,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import CRC32 from 'crc-32';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import * as Reversi from 'misskey-reversi'; import * as Reversi from 'misskey-reversi';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
@ -255,7 +254,13 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
bw = parseInt(game.bw, 10); 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() const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({ .set({
@ -276,12 +281,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
this.cacheGame(updatedGame); this.cacheGame(updatedGame);
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
const engine = new Reversi.Game(updatedGame.map, {
isLlotheo: updatedGame.isLlotheo,
canPutEverywhere: updatedGame.canPutEverywhere,
loopedBoard: updatedGame.loopedBoard,
});
if (engine.isEnded) { if (engine.isEnded) {
let winnerId; let winnerId;
if (engine.winner === true) { if (engine.winner === true) {
@ -406,7 +405,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
const serializeLogs = Reversi.Serializer.serializeLogs(logs); const serializeLogs = Reversi.Serializer.serializeLogs(logs);
const crc32 = CRC32.str(JSON.stringify(serializeLogs)).toString(); const crc32 = engine.calcCrc32().toString();
const updatedGame = { const updatedGame = {
...game, ...game,
@ -536,7 +535,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
if (game == null) throw new Error('game not found'); if (game == null) throw new Error('game not found');
if (crc32.toString() !== game.crc32) { if (crc32.toString() !== game.crc32) {
return await this.reversiGameEntityService.packDetail(game); return game;
} else { } else {
return null; return null;
} }

View file

@ -372,6 +372,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_invitations from './endpoints/reversi/invitations.js';
import * as ep___reversi_showGame from './endpoints/reversi/show-game.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_surrender from './endpoints/reversi/surrender.js';
import * as ep___reversi_verify from './endpoints/reversi/verify.js';
import { GetterService } from './GetterService.js'; import { GetterService } from './GetterService.js';
import { ApiLoggerService } from './ApiLoggerService.js'; import { ApiLoggerService } from './ApiLoggerService.js';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -742,6 +743,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_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_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_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({ @Module({
imports: [ imports: [
@ -1116,6 +1118,7 @@ const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass
$reversi_invitations, $reversi_invitations,
$reversi_showGame, $reversi_showGame,
$reversi_surrender, $reversi_surrender,
$reversi_verify,
], ],
exports: [ exports: [
$admin_meta, $admin_meta,
@ -1481,6 +1484,7 @@ const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass
$reversi_invitations, $reversi_invitations,
$reversi_showGame, $reversi_showGame,
$reversi_surrender, $reversi_surrender,
$reversi_verify,
], ],
}) })
export class EndpointsModule {} export class EndpointsModule {}

View file

@ -373,6 +373,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_invitations from './endpoints/reversi/invitations.js';
import * as ep___reversi_showGame from './endpoints/reversi/show-game.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_surrender from './endpoints/reversi/surrender.js';
import * as ep___reversi_verify from './endpoints/reversi/verify.js';
const eps = [ const eps = [
['admin/meta', ep___admin_meta], ['admin/meta', ep___admin_meta],
@ -741,6 +742,7 @@ const eps = [
['reversi/invitations', ep___reversi_invitations], ['reversi/invitations', ep___reversi_invitations],
['reversi/show-game', ep___reversi_showGame], ['reversi/show-game', ep___reversi_showGame],
['reversi/surrender', ep___reversi_surrender], ['reversi/surrender', ep___reversi_surrender],
['reversi/verify', ep___reversi_verify],
]; ];
interface IEndpointMetaBase { interface IEndpointMetaBase {

View 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,
};
}
});
}
}

View file

@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; 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 { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { ReversiService } from '@/core/ReversiService.js'; import { ReversiService } from '@/core/ReversiService.js';
@ -19,7 +19,6 @@ class ReversiGameChannel extends Channel {
constructor( constructor(
private reversiService: ReversiService, private reversiService: ReversiService,
private reversiGamesRepository: ReversiGamesRepository,
private reversiGameEntityService: ReversiGameEntityService, private reversiGameEntityService: ReversiGameEntityService,
id: string, id: string,
@ -42,7 +41,6 @@ class ReversiGameChannel extends Channel {
case 'updateSettings': this.updateSettings(body.key, body.value); break; case 'updateSettings': this.updateSettings(body.key, body.value); break;
case 'cancel': this.cancelGame(); break; case 'cancel': this.cancelGame(); break;
case 'putStone': this.putStone(body.pos, body.id); break; case 'putStone': this.putStone(body.pos, body.id); break;
case 'resync': this.resync(body.crc32); break;
case 'claimTimeIsUp': this.claimTimeIsUp(); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break;
} }
} }
@ -75,14 +73,6 @@ class ReversiGameChannel extends Channel {
this.reversiService.putStoneToGame(this.gameId!, this.user, pos, id); 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 @bindThis
private async claimTimeIsUp() { private async claimTimeIsUp() {
if (this.user == null) return; if (this.user == null) return;
@ -104,9 +94,6 @@ export class ReversiGameChannelService implements MiChannelService<false> {
public readonly kind = ReversiGameChannel.kind; public readonly kind = ReversiGameChannel.kind;
constructor( constructor(
@Inject(DI.reversiGamesRepository)
private reversiGamesRepository: ReversiGamesRepository,
private reversiService: ReversiService, private reversiService: ReversiService,
private reversiGameEntityService: ReversiGameEntityService, private reversiGameEntityService: ReversiGameEntityService,
) { ) {
@ -116,7 +103,6 @@ export class ReversiGameChannelService implements MiChannelService<false> {
public create(id: string, connection: Channel['connection']): ReversiGameChannel { public create(id: string, connection: Channel['connection']): ReversiGameChannel {
return new ReversiGameChannel( return new ReversiGameChannel(
this.reversiService, this.reversiService,
this.reversiGamesRepository,
this.reversiGameEntityService, this.reversiGameEntityService,
id, id,
connection, connection,

View file

@ -41,7 +41,6 @@
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.3.1", "chromatic": "10.3.1",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"crc-32": "^1.2.2",
"cropperjs": "2.0.0-beta.4", "cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"defu": "^6.1.4", "defu": "^6.1.4",

View file

@ -143,7 +143,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue'; 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 Misskey from 'misskey-js';
import * as Reversi from 'misskey-reversi'; import * as Reversi from 'misskey-reversi';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
@ -240,11 +239,17 @@ watch(logPos, (v) => {
if (game.value.isStarted && !game.value.isEnded) { if (game.value.isStarted && !game.value.isEnded) {
useInterval(() => { useInterval(() => {
if (game.value.isEnded || props.connection == null) return; if (game.value.isEnded) return;
const crc32 = CRC32.str(JSON.stringify(game.value.logs)).toString(); const crc32 = engine.value.calcCrc32();
if (_DEV_) console.log('crc32', crc32); if (_DEV_) console.log('crc32', crc32);
props.connection.send('resync', { misskeyApi('reversi/verify', {
crc32: crc32, gameId: game.value.id,
crc32: crc32.toString(),
}).then((res) => {
if (res.desynced) {
console.log('resynced');
restoreGame(res.game!);
}
}); });
}, 10000, { immediate: false, afterMounted: true }); }, 10000, { immediate: false, afterMounted: true });
} }
@ -392,12 +397,6 @@ function restoreGame(_game) {
checkEnd(); checkEnd();
} }
function onStreamResynced(_game) {
console.log('resynced');
restoreGame(_game);
}
async function surrender() { async function surrender() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
@ -450,7 +449,6 @@ function share() {
onMounted(() => { onMounted(() => {
if (props.connection != null) { if (props.connection != null) {
props.connection.on('log', onStreamLog); props.connection.on('log', onStreamLog);
props.connection.on('resynced', onStreamResynced);
props.connection.on('ended', onStreamEnded); props.connection.on('ended', onStreamEnded);
} }
}); });
@ -458,7 +456,6 @@ onMounted(() => {
onActivated(() => { onActivated(() => {
if (props.connection != null) { if (props.connection != null) {
props.connection.on('log', onStreamLog); props.connection.on('log', onStreamLog);
props.connection.on('resynced', onStreamResynced);
props.connection.on('ended', onStreamEnded); props.connection.on('ended', onStreamEnded);
} }
}); });
@ -466,7 +463,6 @@ onActivated(() => {
onDeactivated(() => { onDeactivated(() => {
if (props.connection != null) { if (props.connection != null) {
props.connection.off('log', onStreamLog); props.connection.off('log', onStreamLog);
props.connection.off('resynced', onStreamResynced);
props.connection.off('ended', onStreamEnded); props.connection.off('ended', onStreamEnded);
} }
}); });
@ -474,7 +470,6 @@ onDeactivated(() => {
onUnmounted(() => { onUnmounted(() => {
if (props.connection != null) { if (props.connection != null) {
props.connection.off('log', onStreamLog); props.connection.off('log', onStreamLog);
props.connection.off('resynced', onStreamResynced);
props.connection.off('ended', onStreamEnded); props.connection.off('ended', onStreamEnded);
} }
}); });

View file

@ -1633,6 +1633,8 @@ declare namespace entities {
ReversiShowGameRequest, ReversiShowGameRequest,
ReversiShowGameResponse, ReversiShowGameResponse,
ReversiSurrenderRequest, ReversiSurrenderRequest,
ReversiVerifyRequest,
ReversiVerifyResponse,
Error_2 as Error, Error_2 as Error,
UserLite, UserLite,
UserDetailedNotMeOnly, UserDetailedNotMeOnly,
@ -2644,6 +2646,12 @@ type ReversiShowGameResponse = operations['reversi/show-game']['responses']['200
// @public (undocumented) // @public (undocumented)
type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['content']['application/json']; 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) // @public (undocumented)
type Role = components['schemas']['Role']; type Role = components['schemas']['Role'];

View file

@ -1,6 +1,6 @@
/* /*
* version: 2024.2.0-beta.2 * version: 2024.2.0-beta.3
* generatedAt: 2024-01-22T07:11:08.412Z * generatedAt: 2024-01-23T01:22:13.177Z
*/ */
import type { SwitchCaseResponseType } from '../api.js'; import type { SwitchCaseResponseType } from '../api.js';
@ -4073,5 +4073,16 @@ declare module '../api.js' {
params: P, params: P,
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): 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>>;
} }
} }

View file

@ -1,6 +1,6 @@
/* /*
* version: 2024.2.0-beta.2 * version: 2024.2.0-beta.3
* generatedAt: 2024-01-22T07:11:08.410Z * generatedAt: 2024-01-23T01:22:13.175Z
*/ */
import type { import type {
@ -554,6 +554,8 @@ import type {
ReversiShowGameRequest, ReversiShowGameRequest,
ReversiShowGameResponse, ReversiShowGameResponse,
ReversiSurrenderRequest, ReversiSurrenderRequest,
ReversiVerifyRequest,
ReversiVerifyResponse,
} from './entities.js'; } from './entities.js';
export type Endpoints = { export type Endpoints = {
@ -923,4 +925,5 @@ export type Endpoints = {
'reversi/invitations': { req: EmptyRequest; res: ReversiInvitationsResponse }; 'reversi/invitations': { req: EmptyRequest; res: ReversiInvitationsResponse };
'reversi/show-game': { req: ReversiShowGameRequest; res: ReversiShowGameResponse }; 'reversi/show-game': { req: ReversiShowGameRequest; res: ReversiShowGameResponse };
'reversi/surrender': { req: ReversiSurrenderRequest; res: EmptyResponse }; 'reversi/surrender': { req: ReversiSurrenderRequest; res: EmptyResponse };
'reversi/verify': { req: ReversiVerifyRequest; res: ReversiVerifyResponse };
} }

View file

@ -1,6 +1,6 @@
/* /*
* version: 2024.2.0-beta.2 * version: 2024.2.0-beta.3
* generatedAt: 2024-01-22T07:11:08.408Z * generatedAt: 2024-01-23T01:22:13.173Z
*/ */
import { operations } from './types.js'; import { operations } from './types.js';
@ -556,3 +556,5 @@ export type ReversiInvitationsResponse = operations['reversi/invitations']['resp
export type ReversiShowGameRequest = operations['reversi/show-game']['requestBody']['content']['application/json']; 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 ReversiShowGameResponse = operations['reversi/show-game']['responses']['200']['content']['application/json'];
export type ReversiSurrenderRequest = operations['reversi/surrender']['requestBody']['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'];

View file

@ -1,6 +1,6 @@
/* /*
* version: 2024.2.0-beta.2 * version: 2024.2.0-beta.3
* generatedAt: 2024-01-22T07:11:08.408Z * generatedAt: 2024-01-23T01:22:13.172Z
*/ */
import { components } from './types.js'; import { components } from './types.js';

View file

@ -2,8 +2,8 @@
/* eslint @typescript-eslint/no-explicit-any: 0 */ /* eslint @typescript-eslint/no-explicit-any: 0 */
/* /*
* version: 2024.2.0-beta.2 * version: 2024.2.0-beta.3
* generatedAt: 2024-01-22T07:11:08.327Z * generatedAt: 2024-01-23T01:22:13.093Z
*/ */
/** /**
@ -3526,6 +3526,15 @@ export type paths = {
*/ */
post: operations['reversi/surrender']; 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>; export type webhooks = Record<string, never>;
@ -25984,5 +25993,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'];
};
};
};
};
}; };

View file

@ -36,7 +36,8 @@
"built" "built"
], ],
"dependencies": { "dependencies": {
"crc-32": "1.2.2",
"esbuild": "0.19.11", "esbuild": "0.19.11",
"glob": "^10.3.10" "glob": "10.3.10"
} }
} }

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import CRC32 from 'crc-32';
/** /**
* true ... * true ...
* false ... * false ...
@ -204,6 +206,13 @@ export class Game {
return ([] as number[]).concat(...diffVectors.map(effectsInLine)); 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 { public get isEnded(): boolean {
return this.turn === null; return this.turn === null;
} }

View file

@ -185,9 +185,6 @@ importers:
content-disposition: content-disposition:
specifier: 0.5.4 specifier: 0.5.4
version: 0.5.4 version: 0.5.4
crc-32:
specifier: ^1.2.2
version: 1.2.2
date-fns: date-fns:
specifier: 2.30.0 specifier: 2.30.0
version: 2.30.0 version: 2.30.0
@ -742,9 +739,6 @@ importers:
compare-versions: compare-versions:
specifier: 6.1.0 specifier: 6.1.0
version: 6.1.0 version: 6.1.0
crc-32:
specifier: ^1.2.2
version: 1.2.2
cropperjs: cropperjs:
specifier: 2.0.0-beta.4 specifier: 2.0.0-beta.4
version: 2.0.0-beta.4 version: 2.0.0-beta.4
@ -1177,11 +1171,14 @@ importers:
packages/misskey-reversi: packages/misskey-reversi:
dependencies: dependencies:
crc-32:
specifier: 1.2.2
version: 1.2.2
esbuild: esbuild:
specifier: 0.19.11 specifier: 0.19.11
version: 0.19.11 version: 0.19.11
glob: glob:
specifier: ^10.3.10 specifier: 10.3.10
version: 10.3.10 version: 10.3.10
devDependencies: devDependencies:
'@misskey-dev/eslint-plugin': '@misskey-dev/eslint-plugin':