2024-01-19 12:51:49 +01:00
|
|
|
/*
|
2024-02-13 16:59:27 +01:00
|
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
2024-01-19 12:51:49 +01:00
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
|
|
import * as Redis from 'ioredis';
|
|
|
|
import { ModuleRef } from '@nestjs/core';
|
|
|
|
import * as Reversi from 'misskey-reversi';
|
2024-01-24 02:35:44 +01:00
|
|
|
import { IsNull, LessThan, MoreThan } from 'typeorm';
|
2024-01-19 12:51:49 +01:00
|
|
|
import type {
|
|
|
|
MiReversiGame,
|
|
|
|
ReversiGamesRepository,
|
|
|
|
} from '@/models/_.js';
|
|
|
|
import type { MiUser } from '@/models/User.js';
|
|
|
|
import { DI } from '@/di-symbols.js';
|
|
|
|
import { bindThis } from '@/decorators.js';
|
|
|
|
import { CacheService } from '@/core/CacheService.js';
|
|
|
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|
|
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|
|
|
import { IdService } from '@/core/IdService.js';
|
|
|
|
import { NotificationService } from '@/core/NotificationService.js';
|
2024-01-21 02:07:43 +01:00
|
|
|
import { Serialized } from '@/types.js';
|
2024-01-19 12:51:49 +01:00
|
|
|
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
|
|
|
|
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
|
|
|
|
2024-01-24 07:18:50 +01:00
|
|
|
const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
|
2024-01-19 12:51:49 +01:00
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|
|
|
private notificationService: NotificationService;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
private moduleRef: ModuleRef,
|
|
|
|
|
|
|
|
@Inject(DI.redis)
|
|
|
|
private redisClient: Redis.Redis,
|
|
|
|
|
|
|
|
@Inject(DI.reversiGamesRepository)
|
|
|
|
private reversiGamesRepository: ReversiGamesRepository,
|
|
|
|
|
|
|
|
private cacheService: CacheService,
|
|
|
|
private userEntityService: UserEntityService,
|
|
|
|
private globalEventService: GlobalEventService,
|
|
|
|
private reversiGameEntityService: ReversiGameEntityService,
|
|
|
|
private idService: IdService,
|
|
|
|
) {
|
|
|
|
}
|
|
|
|
|
|
|
|
async onModuleInit() {
|
|
|
|
this.notificationService = this.moduleRef.get(NotificationService.name);
|
|
|
|
}
|
|
|
|
|
2024-01-21 02:07:43 +01:00
|
|
|
@bindThis
|
|
|
|
private async cacheGame(game: MiReversiGame) {
|
2024-01-22 00:39:38 +01:00
|
|
|
await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 60, JSON.stringify(game));
|
2024-01-21 02:07:43 +01:00
|
|
|
}
|
|
|
|
|
2024-01-21 04:05:51 +01:00
|
|
|
@bindThis
|
|
|
|
private async deleteGameCache(gameId: MiReversiGame['id']) {
|
|
|
|
await this.redisClient.del(`reversi:game:cache:${gameId}`);
|
|
|
|
}
|
|
|
|
|
2024-01-22 00:39:38 +01:00
|
|
|
@bindThis
|
|
|
|
private getBakeProps(game: MiReversiGame) {
|
|
|
|
return {
|
|
|
|
startedAt: game.startedAt,
|
|
|
|
endedAt: game.endedAt,
|
|
|
|
// ゲームの途中からユーザーが変わることは無いので
|
|
|
|
//user1Id: game.user1Id,
|
|
|
|
//user2Id: game.user2Id,
|
|
|
|
user1Ready: game.user1Ready,
|
|
|
|
user2Ready: game.user2Ready,
|
|
|
|
black: game.black,
|
|
|
|
isStarted: game.isStarted,
|
|
|
|
isEnded: game.isEnded,
|
|
|
|
winnerId: game.winnerId,
|
|
|
|
surrenderedUserId: game.surrenderedUserId,
|
|
|
|
timeoutUserId: game.timeoutUserId,
|
|
|
|
isLlotheo: game.isLlotheo,
|
|
|
|
canPutEverywhere: game.canPutEverywhere,
|
|
|
|
loopedBoard: game.loopedBoard,
|
|
|
|
timeLimitForEachTurn: game.timeLimitForEachTurn,
|
|
|
|
logs: game.logs,
|
|
|
|
map: game.map,
|
|
|
|
bw: game.bw,
|
|
|
|
crc32: game.crc32,
|
2024-01-24 08:37:06 +01:00
|
|
|
noIrregularRules: game.noIrregularRules,
|
2024-01-22 00:39:38 +01:00
|
|
|
} satisfies Partial<MiReversiGame>;
|
|
|
|
}
|
|
|
|
|
2024-01-19 12:51:49 +01:00
|
|
|
@bindThis
|
2024-01-24 02:16:05 +01:00
|
|
|
public async matchSpecificUser(me: MiUser, targetUser: MiUser, multiple = false): Promise<MiReversiGame | null> {
|
2024-01-19 12:51:49 +01:00
|
|
|
if (targetUser.id === me.id) {
|
|
|
|
throw new Error('You cannot match yourself.');
|
|
|
|
}
|
|
|
|
|
2024-01-24 02:16:05 +01:00
|
|
|
if (!multiple) {
|
|
|
|
// 既にマッチしている対局が無いか探す(3分以内)
|
|
|
|
const games = await this.reversiGamesRepository.find({
|
|
|
|
where: [
|
2024-01-24 02:35:44 +01:00
|
|
|
{ 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 },
|
2024-01-24 02:16:05 +01:00
|
|
|
],
|
|
|
|
relations: ['user1', 'user2'],
|
|
|
|
order: { id: 'DESC' },
|
|
|
|
});
|
|
|
|
if (games.length > 0) {
|
|
|
|
return games[0];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
//#region 相手から既に招待されてないか確認
|
2024-01-19 12:51:49 +01:00
|
|
|
const invitations = await this.redisClient.zrange(
|
|
|
|
`reversi:matchSpecific:${me.id}`,
|
2024-01-24 07:18:50 +01:00
|
|
|
Date.now() - INVITATION_TIMEOUT_MS,
|
2024-01-19 12:51:49 +01:00
|
|
|
'+inf',
|
|
|
|
'BYSCORE');
|
|
|
|
|
|
|
|
if (invitations.includes(targetUser.id)) {
|
|
|
|
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
|
|
|
|
|
2024-01-24 08:44:12 +01:00
|
|
|
const game = await this.matched(targetUser.id, me.id, {
|
|
|
|
noIrregularRules: false,
|
|
|
|
});
|
2024-01-19 12:51:49 +01:00
|
|
|
|
|
|
|
return game;
|
2024-01-24 02:16:05 +01:00
|
|
|
}
|
|
|
|
//#endregion
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-24 05:51:16 +01:00
|
|
|
const redisPipeline = this.redisClient.pipeline();
|
|
|
|
redisPipeline.zadd(`reversi:matchSpecific:${targetUser.id}`, Date.now(), me.id);
|
|
|
|
redisPipeline.expire(`reversi:matchSpecific:${targetUser.id}`, 120, 'NX');
|
2024-01-24 05:53:55 +01:00
|
|
|
await redisPipeline.exec();
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-24 02:16:05 +01:00
|
|
|
this.globalEventService.publishReversiStream(targetUser.id, 'invited', {
|
|
|
|
user: await this.userEntityService.pack(me, targetUser),
|
|
|
|
});
|
|
|
|
|
|
|
|
return null;
|
2024-01-19 12:51:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2024-01-24 08:37:06 +01:00
|
|
|
public async matchAnyUser(me: MiUser, options: { noIrregularRules: boolean }, multiple = false): Promise<MiReversiGame | null> {
|
2024-01-24 02:16:05 +01:00
|
|
|
if (!multiple) {
|
|
|
|
// 既にマッチしている対局が無いか探す(3分以内)
|
|
|
|
const games = await this.reversiGamesRepository.find({
|
|
|
|
where: [
|
2024-01-24 02:35:44 +01:00
|
|
|
{ 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 },
|
2024-01-24 02:16:05 +01:00
|
|
|
],
|
|
|
|
relations: ['user1', 'user2'],
|
|
|
|
order: { id: 'DESC' },
|
|
|
|
});
|
|
|
|
if (games.length > 0) {
|
|
|
|
return games[0];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-19 12:51:49 +01:00
|
|
|
//#region まず自分宛ての招待を探す
|
|
|
|
const invitations = await this.redisClient.zrange(
|
|
|
|
`reversi:matchSpecific:${me.id}`,
|
2024-01-24 07:18:50 +01:00
|
|
|
Date.now() - INVITATION_TIMEOUT_MS,
|
2024-01-19 12:51:49 +01:00
|
|
|
'+inf',
|
|
|
|
'BYSCORE');
|
|
|
|
|
|
|
|
if (invitations.length > 0) {
|
|
|
|
const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
|
|
|
|
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
|
|
|
|
|
2024-01-24 08:44:12 +01:00
|
|
|
const game = await this.matched(invitorId, me.id, {
|
|
|
|
noIrregularRules: false,
|
|
|
|
});
|
2024-01-19 12:51:49 +01:00
|
|
|
|
|
|
|
return game;
|
|
|
|
}
|
|
|
|
//#endregion
|
|
|
|
|
|
|
|
const matchings = await this.redisClient.zrange(
|
|
|
|
'reversi:matchAny',
|
2024-01-24 07:18:50 +01:00
|
|
|
0,
|
|
|
|
2, // 自分自身のIDが入っている場合もあるので2つ取得
|
|
|
|
'REV');
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-24 08:37:06 +01:00
|
|
|
const items = matchings.filter(id => !id.startsWith(me.id));
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-24 08:37:06 +01:00
|
|
|
if (items.length > 0) {
|
|
|
|
const [matchedUserId, option] = items[0].split(':');
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-24 08:37:06 +01:00
|
|
|
await this.redisClient.zrem('reversi:matchAny',
|
|
|
|
me.id,
|
|
|
|
matchedUserId,
|
|
|
|
me.id + ':noIrregularRules',
|
|
|
|
matchedUserId + ':noIrregularRules');
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-24 08:37:06 +01:00
|
|
|
const game = await this.matched(matchedUserId, me.id, {
|
|
|
|
noIrregularRules: options.noIrregularRules || option === 'noIrregularRules',
|
|
|
|
});
|
2024-01-19 12:51:49 +01:00
|
|
|
|
|
|
|
return game;
|
|
|
|
} else {
|
2024-01-24 05:51:16 +01:00
|
|
|
const redisPipeline = this.redisClient.pipeline();
|
2024-01-24 08:37:06 +01:00
|
|
|
if (options.noIrregularRules) {
|
|
|
|
redisPipeline.zadd('reversi:matchAny', Date.now(), me.id + ':noIrregularRules');
|
|
|
|
} else {
|
|
|
|
redisPipeline.zadd('reversi:matchAny', Date.now(), me.id);
|
|
|
|
}
|
2024-01-24 07:18:50 +01:00
|
|
|
redisPipeline.expire('reversi:matchAny', 15, 'NX');
|
2024-01-24 05:51:16 +01:00
|
|
|
await redisPipeline.exec();
|
2024-01-19 12:51:49 +01:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
|
|
|
public async matchSpecificUserCancel(user: MiUser, targetUserId: MiUser['id']) {
|
|
|
|
await this.redisClient.zrem(`reversi:matchSpecific:${targetUserId}`, user.id);
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
|
|
|
public async matchAnyUserCancel(user: MiUser) {
|
2024-01-24 08:44:12 +01:00
|
|
|
await this.redisClient.zrem('reversi:matchAny', user.id, user.id + ':noIrregularRules');
|
2024-01-19 12:51:49 +01:00
|
|
|
}
|
|
|
|
|
2024-01-24 02:16:05 +01:00
|
|
|
@bindThis
|
|
|
|
public async cleanOutdatedGames() {
|
|
|
|
await this.reversiGamesRepository.delete({
|
|
|
|
id: LessThan(this.idService.gen(Date.now() - 1000 * 60 * 10)),
|
|
|
|
isStarted: false,
|
|
|
|
});
|
2024-01-19 12:51:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2024-01-21 02:07:43 +01:00
|
|
|
public async gameReady(gameId: MiReversiGame['id'], user: MiUser, ready: boolean) {
|
|
|
|
const game = await this.get(gameId);
|
|
|
|
if (game == null) throw new Error('game not found');
|
2024-01-19 12:51:49 +01:00
|
|
|
if (game.isStarted) return;
|
|
|
|
|
|
|
|
let isBothReady = false;
|
|
|
|
|
|
|
|
if (game.user1Id === user.id) {
|
2024-01-22 00:39:38 +01:00
|
|
|
const updatedGame = {
|
|
|
|
...game,
|
|
|
|
user1Ready: ready,
|
|
|
|
};
|
2024-01-21 02:07:43 +01:00
|
|
|
this.cacheGame(updatedGame);
|
2024-01-19 12:51:49 +01:00
|
|
|
|
|
|
|
this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
|
|
|
|
user1: ready,
|
2024-01-21 02:07:43 +01:00
|
|
|
user2: updatedGame.user2Ready,
|
2024-01-19 12:51:49 +01:00
|
|
|
});
|
|
|
|
|
2024-01-21 02:07:43 +01:00
|
|
|
if (ready && updatedGame.user2Ready) isBothReady = true;
|
2024-01-19 12:51:49 +01:00
|
|
|
} else if (game.user2Id === user.id) {
|
2024-01-22 00:39:38 +01:00
|
|
|
const updatedGame = {
|
|
|
|
...game,
|
|
|
|
user2Ready: ready,
|
|
|
|
};
|
2024-01-21 02:07:43 +01:00
|
|
|
this.cacheGame(updatedGame);
|
2024-01-19 12:51:49 +01:00
|
|
|
|
|
|
|
this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
|
2024-01-21 02:07:43 +01:00
|
|
|
user1: updatedGame.user1Ready,
|
2024-01-19 12:51:49 +01:00
|
|
|
user2: ready,
|
|
|
|
});
|
|
|
|
|
2024-01-21 02:07:43 +01:00
|
|
|
if (ready && updatedGame.user1Ready) isBothReady = true;
|
2024-01-19 12:51:49 +01:00
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isBothReady) {
|
|
|
|
// 3秒後、両者readyならゲーム開始
|
|
|
|
setTimeout(async () => {
|
2024-01-21 04:05:51 +01:00
|
|
|
const freshGame = await this.get(game.id);
|
2024-01-19 12:51:49 +01:00
|
|
|
if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return;
|
|
|
|
if (!freshGame.user1Ready || !freshGame.user2Ready) return;
|
|
|
|
|
2024-01-21 04:05:51 +01:00
|
|
|
this.startGame(freshGame);
|
2024-01-19 12:51:49 +01:00
|
|
|
}, 3000);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-22 01:29:06 +01:00
|
|
|
@bindThis
|
2024-01-24 08:37:06 +01:00
|
|
|
private async matched(parentId: MiUser['id'], childId: MiUser['id'], options: { noIrregularRules: boolean; }): Promise<MiReversiGame> {
|
2024-06-01 04:16:44 +02:00
|
|
|
const game = await this.reversiGamesRepository.insertOne({
|
2024-01-22 01:29:06 +01:00
|
|
|
id: this.idService.gen(),
|
|
|
|
user1Id: parentId,
|
|
|
|
user2Id: childId,
|
|
|
|
user1Ready: false,
|
|
|
|
user2Ready: false,
|
|
|
|
isStarted: false,
|
|
|
|
isEnded: false,
|
|
|
|
logs: [],
|
|
|
|
map: Reversi.maps.eighteight.data,
|
|
|
|
bw: 'random',
|
|
|
|
isLlotheo: false,
|
2024-01-24 08:37:06 +01:00
|
|
|
noIrregularRules: options.noIrregularRules,
|
2024-06-01 04:16:44 +02:00
|
|
|
}, { relations: ['user1', 'user2'] });
|
2024-01-22 01:29:06 +01:00
|
|
|
this.cacheGame(game);
|
|
|
|
|
2024-01-22 07:41:29 +01:00
|
|
|
const packed = await this.reversiGameEntityService.packDetail(game);
|
2024-01-22 01:29:06 +01:00
|
|
|
this.globalEventService.publishReversiStream(parentId, 'matched', { game: packed });
|
|
|
|
|
|
|
|
return game;
|
|
|
|
}
|
|
|
|
|
2024-01-21 04:05:51 +01:00
|
|
|
@bindThis
|
|
|
|
private async startGame(game: MiReversiGame) {
|
|
|
|
let bw: number;
|
|
|
|
if (game.bw === 'random') {
|
|
|
|
bw = Math.random() > 0.5 ? 1 : 2;
|
|
|
|
} else {
|
|
|
|
bw = parseInt(game.bw, 10);
|
|
|
|
}
|
|
|
|
|
2024-01-23 02:51:59 +01:00
|
|
|
const engine = new Reversi.Game(game.map, {
|
|
|
|
isLlotheo: game.isLlotheo,
|
|
|
|
canPutEverywhere: game.canPutEverywhere,
|
|
|
|
loopedBoard: game.loopedBoard,
|
|
|
|
});
|
|
|
|
|
|
|
|
const crc32 = engine.calcCrc32().toString();
|
2024-01-21 04:05:51 +01:00
|
|
|
|
|
|
|
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
|
|
|
|
.set({
|
2024-01-22 00:39:38 +01:00
|
|
|
...this.getBakeProps(game),
|
2024-01-21 04:05:51 +01:00
|
|
|
startedAt: new Date(),
|
|
|
|
isStarted: true,
|
|
|
|
black: bw,
|
2024-01-22 00:39:38 +01:00
|
|
|
map: game.map,
|
2024-01-21 04:05:51 +01:00
|
|
|
crc32,
|
|
|
|
})
|
|
|
|
.where('id = :id', { id: game.id })
|
|
|
|
.returning('*')
|
|
|
|
.execute()
|
|
|
|
.then((response) => response.raw[0]);
|
2024-01-22 07:41:29 +01:00
|
|
|
// キャッシュ効率化のためにユーザー情報は再利用
|
|
|
|
updatedGame.user1 = game.user1;
|
|
|
|
updatedGame.user2 = game.user2;
|
2024-01-21 04:05:51 +01:00
|
|
|
this.cacheGame(updatedGame);
|
|
|
|
|
|
|
|
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
|
|
|
|
if (engine.isEnded) {
|
2024-01-22 00:39:38 +01:00
|
|
|
let winnerId;
|
2024-01-21 04:05:51 +01:00
|
|
|
if (engine.winner === true) {
|
2024-01-22 00:39:38 +01:00
|
|
|
winnerId = bw === 1 ? updatedGame.user1Id : updatedGame.user2Id;
|
2024-01-21 04:05:51 +01:00
|
|
|
} else if (engine.winner === false) {
|
2024-01-22 00:39:38 +01:00
|
|
|
winnerId = bw === 1 ? updatedGame.user2Id : updatedGame.user1Id;
|
2024-01-21 04:05:51 +01:00
|
|
|
} else {
|
2024-01-22 00:39:38 +01:00
|
|
|
winnerId = null;
|
2024-01-21 04:05:51 +01:00
|
|
|
}
|
|
|
|
|
2024-01-22 00:39:38 +01:00
|
|
|
await this.endGame(updatedGame, winnerId, null);
|
2024-01-21 04:05:51 +01:00
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
//#endregion
|
|
|
|
|
|
|
|
this.redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, updatedGame.timeLimitForEachTurn, '');
|
|
|
|
|
|
|
|
this.globalEventService.publishReversiGameStream(game.id, 'started', {
|
2024-01-22 00:39:38 +01:00
|
|
|
game: await this.reversiGameEntityService.packDetail(updatedGame),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
|
|
|
private async endGame(game: MiReversiGame, winnerId: MiUser['id'] | null, reason: 'surrender' | 'timeout' | null) {
|
|
|
|
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
|
|
|
|
.set({
|
|
|
|
...this.getBakeProps(game),
|
|
|
|
isEnded: true,
|
|
|
|
endedAt: new Date(),
|
|
|
|
winnerId: winnerId,
|
|
|
|
surrenderedUserId: reason === 'surrender' ? (winnerId === game.user1Id ? game.user2Id : game.user1Id) : null,
|
|
|
|
timeoutUserId: reason === 'timeout' ? (winnerId === game.user1Id ? game.user2Id : game.user1Id) : null,
|
|
|
|
})
|
|
|
|
.where('id = :id', { id: game.id })
|
|
|
|
.returning('*')
|
|
|
|
.execute()
|
|
|
|
.then((response) => response.raw[0]);
|
2024-01-22 07:41:29 +01:00
|
|
|
// キャッシュ効率化のためにユーザー情報は再利用
|
|
|
|
updatedGame.user1 = game.user1;
|
|
|
|
updatedGame.user2 = game.user2;
|
2024-01-22 00:39:38 +01:00
|
|
|
this.cacheGame(updatedGame);
|
|
|
|
|
|
|
|
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
|
|
|
|
winnerId: winnerId,
|
|
|
|
game: await this.reversiGameEntityService.packDetail(updatedGame),
|
2024-01-21 04:05:51 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-01-19 12:51:49 +01:00
|
|
|
@bindThis
|
|
|
|
public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
|
|
|
|
const invitations = await this.redisClient.zrange(
|
|
|
|
`reversi:matchSpecific:${user.id}`,
|
2024-01-24 07:18:50 +01:00
|
|
|
Date.now() - INVITATION_TIMEOUT_MS,
|
2024-01-19 12:51:49 +01:00
|
|
|
'+inf',
|
|
|
|
'BYSCORE');
|
|
|
|
return invitations;
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2024-01-21 02:07:43 +01:00
|
|
|
public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: string, value: any) {
|
|
|
|
const game = await this.get(gameId);
|
|
|
|
if (game == null) throw new Error('game not found');
|
2024-01-19 12:51:49 +01:00
|
|
|
if (game.isStarted) return;
|
|
|
|
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
|
|
|
|
if ((game.user1Id === user.id) && game.user1Ready) return;
|
|
|
|
if ((game.user2Id === user.id) && game.user2Ready) return;
|
|
|
|
|
2024-01-21 02:07:43 +01:00
|
|
|
if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn'].includes(key)) return;
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-21 02:07:43 +01:00
|
|
|
// TODO: より厳格なバリデーション
|
|
|
|
|
2024-01-22 00:39:38 +01:00
|
|
|
const updatedGame = {
|
|
|
|
...game,
|
|
|
|
[key]: value,
|
|
|
|
};
|
2024-01-21 02:07:43 +01:00
|
|
|
this.cacheGame(updatedGame);
|
2024-01-19 12:51:49 +01:00
|
|
|
|
|
|
|
this.globalEventService.publishReversiGameStream(game.id, 'updateSettings', {
|
|
|
|
userId: user.id,
|
|
|
|
key: key,
|
|
|
|
value: value,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2024-01-21 02:07:43 +01:00
|
|
|
public async putStoneToGame(gameId: MiReversiGame['id'], user: MiUser, pos: number, id?: string | null) {
|
|
|
|
const game = await this.get(gameId);
|
|
|
|
if (game == null) throw new Error('game not found');
|
2024-01-19 12:51:49 +01:00
|
|
|
if (!game.isStarted) return;
|
|
|
|
if (game.isEnded) return;
|
|
|
|
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
|
|
|
|
|
|
|
|
const myColor =
|
|
|
|
((game.user1Id === user.id) && game.black === 1) || ((game.user2Id === user.id) && game.black === 2)
|
|
|
|
? true
|
|
|
|
: false;
|
|
|
|
|
2024-01-20 05:14:46 +01:00
|
|
|
const engine = Reversi.Serializer.restoreGame({
|
|
|
|
map: game.map,
|
2024-01-19 12:51:49 +01:00
|
|
|
isLlotheo: game.isLlotheo,
|
|
|
|
canPutEverywhere: game.canPutEverywhere,
|
|
|
|
loopedBoard: game.loopedBoard,
|
2024-01-20 05:14:46 +01:00
|
|
|
logs: game.logs,
|
2024-01-19 12:51:49 +01:00
|
|
|
});
|
|
|
|
|
2024-01-20 05:14:46 +01:00
|
|
|
if (engine.turn !== myColor) return;
|
|
|
|
if (!engine.canPut(myColor, pos)) return;
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-20 05:14:46 +01:00
|
|
|
engine.putStone(pos);
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-20 05:14:46 +01:00
|
|
|
const logs = Reversi.Serializer.deserializeLogs(game.logs);
|
|
|
|
|
2024-01-19 12:51:49 +01:00
|
|
|
const log = {
|
2024-01-20 05:14:46 +01:00
|
|
|
time: Date.now(),
|
|
|
|
player: myColor,
|
|
|
|
operation: 'put',
|
2024-01-19 12:51:49 +01:00
|
|
|
pos,
|
2024-01-20 05:14:46 +01:00
|
|
|
} as const;
|
|
|
|
|
|
|
|
logs.push(log);
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-20 05:14:46 +01:00
|
|
|
const serializeLogs = Reversi.Serializer.serializeLogs(logs);
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-23 02:51:59 +01:00
|
|
|
const crc32 = engine.calcCrc32().toString();
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-22 00:39:38 +01:00
|
|
|
const updatedGame = {
|
|
|
|
...game,
|
|
|
|
crc32,
|
|
|
|
logs: serializeLogs,
|
|
|
|
};
|
2024-01-21 02:07:43 +01:00
|
|
|
this.cacheGame(updatedGame);
|
2024-01-19 12:51:49 +01:00
|
|
|
|
2024-01-20 05:14:46 +01:00
|
|
|
this.globalEventService.publishReversiGameStream(game.id, 'log', {
|
2024-01-19 12:51:49 +01:00
|
|
|
...log,
|
2024-01-20 05:14:46 +01:00
|
|
|
id: id ?? null,
|
2024-01-19 12:51:49 +01:00
|
|
|
});
|
|
|
|
|
2024-01-20 05:14:46 +01:00
|
|
|
if (engine.isEnded) {
|
2024-01-22 00:39:38 +01:00
|
|
|
let winnerId;
|
|
|
|
if (engine.winner === true) {
|
|
|
|
winnerId = game.black === 1 ? game.user1Id : game.user2Id;
|
|
|
|
} else if (engine.winner === false) {
|
|
|
|
winnerId = game.black === 1 ? game.user2Id : game.user1Id;
|
|
|
|
} else {
|
|
|
|
winnerId = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.endGame(updatedGame, winnerId, null);
|
2024-01-21 02:07:43 +01:00
|
|
|
} else {
|
|
|
|
this.redisClient.setex(`reversi:game:turnTimer:${game.id}:${engine.turn ? '1' : '0'}`, updatedGame.timeLimitForEachTurn, '');
|
2024-01-19 12:51:49 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2024-01-21 02:07:43 +01:00
|
|
|
public async surrender(gameId: MiReversiGame['id'], user: MiUser) {
|
|
|
|
const game = await this.get(gameId);
|
|
|
|
if (game == null) throw new Error('game not found');
|
2024-01-19 12:51:49 +01:00
|
|
|
if (game.isEnded) return;
|
|
|
|
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
|
|
|
|
|
|
|
|
const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id;
|
|
|
|
|
2024-01-22 00:39:38 +01:00
|
|
|
await this.endGame(game, winnerId, 'surrender');
|
2024-01-19 12:51:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2024-01-21 02:07:43 +01:00
|
|
|
public async checkTimeout(gameId: MiReversiGame['id']) {
|
|
|
|
const game = await this.get(gameId);
|
|
|
|
if (game == null) throw new Error('game not found');
|
|
|
|
if (game.isEnded) return;
|
|
|
|
|
|
|
|
const engine = Reversi.Serializer.restoreGame({
|
|
|
|
map: game.map,
|
|
|
|
isLlotheo: game.isLlotheo,
|
|
|
|
canPutEverywhere: game.canPutEverywhere,
|
|
|
|
loopedBoard: game.loopedBoard,
|
|
|
|
logs: game.logs,
|
|
|
|
});
|
|
|
|
|
|
|
|
if (engine.turn == null) return;
|
|
|
|
|
|
|
|
const timer = await this.redisClient.exists(`reversi:game:turnTimer:${game.id}:${engine.turn ? '1' : '0'}`);
|
|
|
|
|
|
|
|
if (timer === 0) {
|
|
|
|
const winnerId = engine.turn ? (game.black === 1 ? game.user2Id : game.user1Id) : (game.black === 1 ? game.user1Id : game.user2Id);
|
|
|
|
|
2024-01-22 00:39:38 +01:00
|
|
|
await this.endGame(game, winnerId, 'timeout');
|
2024-01-21 02:07:43 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-21 04:05:51 +01:00
|
|
|
@bindThis
|
|
|
|
public async cancelGame(gameId: MiReversiGame['id'], user: MiUser) {
|
|
|
|
const game = await this.get(gameId);
|
|
|
|
if (game == null) throw new Error('game not found');
|
|
|
|
if (game.isStarted) return;
|
|
|
|
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
|
|
|
|
|
|
|
|
await this.reversiGamesRepository.delete(game.id);
|
|
|
|
this.deleteGameCache(game.id);
|
|
|
|
|
|
|
|
this.globalEventService.publishReversiGameStream(game.id, 'canceled', {
|
|
|
|
userId: user.id,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-01-21 02:07:43 +01:00
|
|
|
@bindThis
|
|
|
|
public async get(id: MiReversiGame['id']): Promise<MiReversiGame | null> {
|
|
|
|
const cached = await this.redisClient.get(`reversi:game:cache:${id}`);
|
|
|
|
if (cached != null) {
|
2024-01-22 07:41:29 +01:00
|
|
|
// TODO: この辺りのデシリアライズ処理をどこか別のサービスに切り出したい
|
2024-01-21 02:07:43 +01:00
|
|
|
const parsed = JSON.parse(cached) as Serialized<MiReversiGame>;
|
|
|
|
return {
|
|
|
|
...parsed,
|
|
|
|
startedAt: parsed.startedAt != null ? new Date(parsed.startedAt) : null,
|
|
|
|
endedAt: parsed.endedAt != null ? new Date(parsed.endedAt) : null,
|
2024-01-22 07:41:29 +01:00
|
|
|
user1: parsed.user1 != null ? {
|
|
|
|
...parsed.user1,
|
|
|
|
avatar: null,
|
|
|
|
banner: null,
|
2024-01-22 22:13:10 +01:00
|
|
|
background: null,
|
2024-01-22 07:41:29 +01:00
|
|
|
updatedAt: parsed.user1.updatedAt != null ? new Date(parsed.user1.updatedAt) : null,
|
|
|
|
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
|
|
|
|
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
|
|
|
|
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
|
|
|
|
} : null,
|
|
|
|
user2: parsed.user2 != null ? {
|
|
|
|
...parsed.user2,
|
|
|
|
avatar: null,
|
|
|
|
banner: null,
|
2024-01-22 22:13:10 +01:00
|
|
|
background: null,
|
2024-01-22 07:41:29 +01:00
|
|
|
updatedAt: parsed.user2.updatedAt != null ? new Date(parsed.user2.updatedAt) : null,
|
|
|
|
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
|
|
|
|
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
|
|
|
|
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
|
|
|
|
} : null,
|
2024-01-21 02:07:43 +01:00
|
|
|
};
|
|
|
|
} else {
|
2024-01-22 07:41:29 +01:00
|
|
|
const game = await this.reversiGamesRepository.findOne({
|
|
|
|
where: { id },
|
|
|
|
relations: ['user1', 'user2'],
|
|
|
|
});
|
2024-01-21 02:07:43 +01:00
|
|
|
if (game == null) return null;
|
|
|
|
|
|
|
|
this.cacheGame(game);
|
|
|
|
|
|
|
|
return game;
|
|
|
|
}
|
2024-01-19 12:51:49 +01:00
|
|
|
}
|
|
|
|
|
2024-01-20 13:23:33 +01:00
|
|
|
@bindThis
|
2024-01-21 02:07:43 +01:00
|
|
|
public async checkCrc(gameId: MiReversiGame['id'], crc32: string | number) {
|
|
|
|
const game = await this.get(gameId);
|
|
|
|
if (game == null) throw new Error('game not found');
|
|
|
|
|
|
|
|
if (crc32.toString() !== game.crc32) {
|
2024-01-23 02:51:59 +01:00
|
|
|
return game;
|
2024-01-21 02:07:43 +01:00
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
}
|
2024-01-20 13:23:33 +01:00
|
|
|
}
|
|
|
|
|
2024-01-19 12:51:49 +01:00
|
|
|
@bindThis
|
|
|
|
public dispose(): void {
|
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
|
|
|
public onApplicationShutdown(signal?: string | undefined): void {
|
|
|
|
this.dispose();
|
|
|
|
}
|
|
|
|
}
|