merge: upstream (1)

This commit is contained in:
Marie 2024-01-21 13:11:23 +01:00
commit db012fc8c3
No known key found for this signature in database
GPG key ID: 56569BBE47D2C828
258 changed files with 18802 additions and 7557 deletions

View file

@ -66,6 +66,8 @@ import { FeaturedService } from './FeaturedService.js';
import { FanoutTimelineService } from './FanoutTimelineService.js';
import { ChannelFollowingService } from './ChannelFollowingService.js';
import { RegistryApiService } from './RegistryApiService.js';
import { ReversiService } from './ReversiService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js';
import NotesChart from './chart/charts/notes.js';
@ -80,6 +82,7 @@ import PerUserFollowingChart from './chart/charts/per-user-following.js';
import PerUserDriveChart from './chart/charts/per-user-drive.js';
import ApRequestChart from './chart/charts/ap-request.js';
import { ChartManagementService } from './chart/ChartManagementService.js';
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
import { AntennaEntityService } from './entities/AntennaEntityService.js';
import { AppEntityService } from './entities/AppEntityService.js';
@ -112,6 +115,8 @@ import { UserListEntityService } from './entities/UserListEntityService.js';
import { FlashEntityService } from './entities/FlashEntityService.js';
import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
import { RoleEntityService } from './entities/RoleEntityService.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import { ApAudienceService } from './activitypub/ApAudienceService.js';
import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
@ -199,6 +204,7 @@ const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', use
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@ -247,6 +253,7 @@ const $UserListEntityService: Provider = { provide: 'UserListEntityService', use
const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService };
const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService };
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
@ -336,6 +343,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineEndpointService,
ChannelFollowingService,
RegistryApiService,
ReversiService,
ChartLoggerService,
FederationChart,
NotesChart,
@ -350,6 +359,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
PerUserDriveChart,
ApRequestChart,
ChartManagementService,
AbuseUserReportEntityService,
AntennaEntityService,
AppEntityService,
@ -382,6 +392,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FlashEntityService,
FlashLikeEntityService,
RoleEntityService,
ReversiGameEntityService,
ApAudienceService,
ApDbResolverService,
ApDeliverManagerService,
@ -466,6 +478,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineEndpointService,
$ChannelFollowingService,
$RegistryApiService,
$ReversiService,
$ChartLoggerService,
$FederationChart,
$NotesChart,
@ -480,6 +494,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$PerUserDriveChart,
$ApRequestChart,
$ChartManagementService,
$AbuseUserReportEntityService,
$AntennaEntityService,
$AppEntityService,
@ -512,6 +527,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FlashEntityService,
$FlashLikeEntityService,
$RoleEntityService,
$ReversiGameEntityService,
$ApAudienceService,
$ApDbResolverService,
$ApDeliverManagerService,
@ -597,6 +614,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineEndpointService,
ChannelFollowingService,
RegistryApiService,
ReversiService,
FederationChart,
NotesChart,
UsersChart,
@ -610,6 +629,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
PerUserDriveChart,
ApRequestChart,
ChartManagementService,
AbuseUserReportEntityService,
AntennaEntityService,
AppEntityService,
@ -642,6 +662,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FlashEntityService,
FlashLikeEntityService,
RoleEntityService,
ReversiGameEntityService,
ApAudienceService,
ApDbResolverService,
ApDeliverManagerService,
@ -726,6 +748,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineEndpointService,
$ChannelFollowingService,
$RegistryApiService,
$ReversiService,
$FederationChart,
$NotesChart,
$UsersChart,
@ -739,6 +763,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$PerUserDriveChart,
$ApRequestChart,
$ChartManagementService,
$AbuseUserReportEntityService,
$AntennaEntityService,
$AppEntityService,
@ -771,6 +796,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FlashEntityService,
$FlashLikeEntityService,
$RoleEntityService,
$ReversiGameEntityService,
$ApAudienceService,
$ApDbResolverService,
$ApDeliverManagerService,

View file

@ -165,10 +165,17 @@ export class EmailService {
email: emailAddress,
});
if (exist !== 0) {
return {
available: false,
reason: 'used',
};
}
let validated: {
valid: boolean,
reason?: string | null,
};
} = { valid: true, reason: null };
if (meta.enableActiveEmailValidation) {
if (meta.enableVerifymailApi && meta.verifymailAuthKey != null) {
@ -185,27 +192,37 @@ export class EmailService {
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
});
}
} else {
validated = { valid: true, reason: null };
}
if (!validated.valid) {
const formatReason: Record<string, 'format' | 'disposable' | 'mx' | 'smtp' | 'network' | 'blacklist' | undefined> = {
regex: 'format',
disposable: 'disposable',
mx: 'mx',
smtp: 'smtp',
network: 'network',
blacklist: 'blacklist',
};
return {
available: false,
reason: validated.reason ? formatReason[validated.reason] ?? null : null,
};
}
const emailDomain: string = emailAddress.split('@')[1];
const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain);
const available = exist === 0 && validated.valid && !isBanned;
if (isBanned) {
return {
available: false,
reason: 'banned',
};
}
return {
available,
reason: available ? null :
exist !== 0 ? 'used' :
isBanned ? 'banned' :
validated.reason === 'regex' ? 'format' :
validated.reason === 'disposable' ? 'disposable' :
validated.reason === 'mx' ? 'mx' :
validated.reason === 'smtp' ? 'smtp' :
validated.reason === 'network' ? 'network' :
validated.reason === 'blacklist' ? 'blacklist' :
null,
available: true,
reason: null,
};
}
@ -222,7 +239,8 @@ export class EmailService {
},
});
const json = (await res.json()) as {
const json = (await res.json()) as Partial<{
message: string;
block: boolean;
catch_all: boolean;
deliverable_email: boolean;
@ -237,8 +255,15 @@ export class EmailService {
mx_priority: { [key: string]: number };
privacy: boolean;
related_domains: string[];
};
}>;
/* api error: when there is only one `message` attribute in the returned result */
if (Object.keys(json).length === 1 && Reflect.has(json, 'message')) {
return {
valid: false,
reason: null,
};
}
if (json.email_address === undefined) {
return {
valid: false,
@ -281,25 +306,26 @@ export class EmailService {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: truemailAuthKey
Authorization: truemailAuthKey,
},
});
const json = (await res.json()) as {
email: string;
success: boolean;
errors?: {
error?: string;
errors?: {
list_match?: string;
regex?: string;
mx?: string;
smtp?: string;
} | null;
};
if (json.email === undefined || (json.email !== undefined && json.errors?.regex)) {
if (json.email === undefined || json.errors?.regex) {
return {
valid: false,
reason: 'format',
valid: false,
reason: 'format',
};
}
if (json.errors?.smtp) {
@ -320,7 +346,7 @@ export class EmailService {
reason: json.errors?.list_match as T || 'blacklist',
};
}
return {
valid: true,
reason: null,

View file

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as Reversi from 'misskey-reversi';
import type { MiChannel } from '@/models/Channel.js';
import type { MiUser } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
@ -18,7 +19,7 @@ import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiMeta } from '@/models/Meta.js';
import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js';
import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@ -162,6 +163,38 @@ export interface AdminEventTypes {
comment: string;
};
}
export interface ReversiEventTypes {
matched: {
game: Packed<'ReversiGameDetailed'>;
};
invited: {
user: Packed<'User'>;
};
}
export interface ReversiGameEventTypes {
changeReadyStates: {
user1: boolean;
user2: boolean;
};
updateSettings: {
userId: MiUser['id'];
key: string;
value: any;
};
log: Reversi.Serializer.Log & { id: string | null };
started: {
game: Packed<'ReversiGameDetailed'>;
};
ended: {
winnerId: MiUser['id'] | null;
game: Packed<'ReversiGameDetailed'>;
};
canceled: {
userId: MiUser['id'];
};
}
//#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義
@ -252,6 +285,14 @@ export type GlobalEvents = {
name: 'notesStream';
payload: Serialized<Packed<'Note'>>;
};
reversi: {
name: `reversiStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<ReversiEventTypes>>;
};
reversiGame: {
name: `reversiGameStream:${MiReversiGame['id']}`;
payload: EventUnionFromDictionary<SerializedAll<ReversiGameEventTypes>>;
};
};
// API event definitions
@ -341,4 +382,14 @@ export class GlobalEventService {
public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void {
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void {
this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
}
}

View file

@ -0,0 +1,578 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
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 type {
MiReversiGame,
ReversiGamesRepository,
UsersRepository,
} from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import type { Packed } from '@/misc/json-schema.js';
import { NotificationService } from '@/core/NotificationService.js';
import { Serialized } from '@/types.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
const MATCHING_TIMEOUT_MS = 1000 * 15; // 15sec
@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);
}
@bindThis
private async cacheGame(game: MiReversiGame) {
await this.redisClient.setex(`reversi:game:cache:${game.id}`, 60 * 3, JSON.stringify(game));
}
@bindThis
private async deleteGameCache(gameId: MiReversiGame['id']) {
await this.redisClient.del(`reversi:game:cache:${gameId}`);
}
@bindThis
public async matchSpecificUser(me: MiUser, targetUser: MiUser): Promise<MiReversiGame | null> {
if (targetUser.id === me.id) {
throw new Error('You cannot match yourself.');
}
const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${me.id}`,
Date.now() - MATCHING_TIMEOUT_MS,
'+inf',
'BYSCORE');
if (invitations.includes(targetUser.id)) {
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, targetUser.id);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: targetUser.id,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
this.cacheGame(game);
const packed = await this.reversiGameEntityService.packDetail(game, { id: targetUser.id });
this.globalEventService.publishReversiStream(targetUser.id, 'matched', { game: packed });
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),
});
return null;
}
}
@bindThis
public async matchAnyUser(me: MiUser): Promise<MiReversiGame | null> {
//#region まず自分宛ての招待を探す
const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${me.id}`,
Date.now() - MATCHING_TIMEOUT_MS,
'+inf',
'BYSCORE');
if (invitations.length > 0) {
const invitorId = invitations[Math.floor(Math.random() * invitations.length)];
await this.redisClient.zrem(`reversi:matchSpecific:${me.id}`, invitorId);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: invitorId,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
this.cacheGame(game);
const packed = await this.reversiGameEntityService.packDetail(game, { id: invitorId });
this.globalEventService.publishReversiStream(invitorId, 'matched', { game: packed });
return game;
}
//#endregion
const matchings = await this.redisClient.zrange(
'reversi:matchAny',
Date.now() - MATCHING_TIMEOUT_MS,
'+inf',
'BYSCORE');
const userIds = matchings.filter(id => id !== me.id);
if (userIds.length > 0) {
// pick random
const matchedUserId = userIds[Math.floor(Math.random() * userIds.length)];
await this.redisClient.zrem('reversi:matchAny', me.id, matchedUserId);
const game = await this.reversiGamesRepository.insert({
id: this.idService.gen(),
user1Id: matchedUserId,
user2Id: me.id,
user1Ready: false,
user2Ready: false,
isStarted: false,
isEnded: false,
logs: [],
map: Reversi.maps.eighteight.data,
bw: 'random',
isLlotheo: false,
}).then(x => this.reversiGamesRepository.findOneByOrFail(x.identifiers[0]));
this.cacheGame(game);
const packed = await this.reversiGameEntityService.packDetail(game, { id: matchedUserId });
this.globalEventService.publishReversiStream(matchedUserId, 'matched', { game: packed });
return game;
} else {
await this.redisClient.zadd('reversi:matchAny', Date.now(), me.id);
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) {
await this.redisClient.zrem('reversi:matchAny', user.id);
}
@bindThis
public async gameReady(gameId: MiReversiGame['id'], user: MiUser, ready: boolean) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
let isBothReady = false;
if (game.user1Id === user.id) {
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
user1Ready: ready,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
user1: ready,
user2: updatedGame.user2Ready,
});
if (ready && updatedGame.user2Ready) isBothReady = true;
} else if (game.user2Id === user.id) {
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
user2Ready: ready,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'changeReadyStates', {
user1: updatedGame.user1Ready,
user2: ready,
});
if (ready && updatedGame.user1Ready) isBothReady = true;
} else {
return;
}
if (isBothReady) {
// 3秒後、両者readyならゲーム開始
setTimeout(async () => {
const freshGame = await this.get(game.id);
if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return;
if (!freshGame.user1Ready || !freshGame.user2Ready) return;
this.startGame(freshGame);
}, 3000);
}
}
@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);
}
function getRandomMap() {
const mapCount = Object.entries(Reversi.maps).length;
const rnd = Math.floor(Math.random() * mapCount);
return Object.values(Reversi.maps)[rnd].data;
}
const map = game.map != null ? game.map : getRandomMap();
const crc32 = CRC32.str(JSON.stringify(game.logs)).toString();
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
startedAt: new Date(),
isStarted: true,
black: bw,
map: map,
crc32,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
const engine = new Reversi.Game(map, {
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
});
if (engine.isEnded) {
let winner;
if (engine.winner === true) {
winner = bw === 1 ? game.user1Id : game.user2Id;
} else if (engine.winner === false) {
winner = bw === 1 ? game.user2Id : game.user1Id;
} else {
winner = null;
}
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
isEnded: true,
endedAt: new Date(),
winnerId: winner,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winner,
game: await this.reversiGameEntityService.packDetail(game.id),
});
return;
}
//#endregion
this.redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, updatedGame.timeLimitForEachTurn, '');
this.globalEventService.publishReversiGameStream(game.id, 'started', {
game: await this.reversiGameEntityService.packDetail(game.id),
});
}
@bindThis
public async getInvitations(user: MiUser): Promise<MiUser['id'][]> {
const invitations = await this.redisClient.zrange(
`reversi:matchSpecific:${user.id}`,
Date.now() - MATCHING_TIMEOUT_MS,
'+inf',
'BYSCORE');
return invitations;
}
@bindThis
public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: string, value: any) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
if ((game.user1Id === user.id) && game.user1Ready) return;
if ((game.user2Id === user.id) && game.user2Ready) return;
if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn'].includes(key)) return;
// TODO: より厳格なバリデーション
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
[key]: value,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'updateSettings', {
userId: user.id,
key: key,
value: value,
});
}
@bindThis
public async putStoneToGame(gameId: MiReversiGame['id'], user: MiUser, pos: number, id?: string | null) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (!game.isStarted) return;
if (game.isEnded) return;
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
const myColor =
((game.user1Id === user.id) && game.black === 1) || ((game.user2Id === user.id) && game.black === 2)
? true
: false;
const engine = Reversi.Serializer.restoreGame({
map: game.map,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
logs: game.logs,
});
if (engine.turn !== myColor) return;
if (!engine.canPut(myColor, pos)) return;
engine.putStone(pos);
let winner;
if (engine.isEnded) {
if (engine.winner === true) {
winner = game.black === 1 ? game.user1Id : game.user2Id;
} else if (engine.winner === false) {
winner = game.black === 1 ? game.user2Id : game.user1Id;
} else {
winner = null;
}
}
const logs = Reversi.Serializer.deserializeLogs(game.logs);
const log = {
time: Date.now(),
player: myColor,
operation: 'put',
pos,
} as const;
logs.push(log);
const serializeLogs = Reversi.Serializer.serializeLogs(logs);
const crc32 = CRC32.str(JSON.stringify(serializeLogs)).toString();
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
crc32,
isEnded: engine.isEnded,
winnerId: winner,
logs: serializeLogs,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'log', {
...log,
id: id ?? null,
});
if (engine.isEnded) {
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winner ?? null,
game: await this.reversiGameEntityService.packDetail(game.id),
});
} else {
this.redisClient.setex(`reversi:game:turnTimer:${game.id}:${engine.turn ? '1' : '0'}`, updatedGame.timeLimitForEachTurn, '');
}
}
@bindThis
public async surrender(gameId: MiReversiGame['id'], user: MiUser) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (game.isEnded) return;
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return;
const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id;
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
isEnded: true,
endedAt: new Date(),
winnerId: winnerId,
surrenderedUserId: user.id,
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winnerId,
game: await this.reversiGameEntityService.packDetail(game.id),
});
}
@bindThis
public async checkTimeout(gameId: MiReversiGame['id']) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (game.isEnded) return;
const engine = Reversi.Serializer.restoreGame({
map: game.map,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
logs: game.logs,
});
if (engine.turn == null) return;
const timer = await this.redisClient.exists(`reversi:game:turnTimer:${game.id}:${engine.turn ? '1' : '0'}`);
if (timer === 0) {
const winnerId = engine.turn ? (game.black === 1 ? game.user2Id : game.user1Id) : (game.black === 1 ? game.user1Id : game.user2Id);
const updatedGame = await this.reversiGamesRepository.createQueryBuilder().update()
.set({
isEnded: true,
endedAt: new Date(),
winnerId: winnerId,
timeoutUserId: engine.turn ? (game.black === 1 ? game.user1Id : game.user2Id) : (game.black === 1 ? game.user2Id : game.user1Id),
})
.where('id = :id', { id: game.id })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.cacheGame(updatedGame);
this.globalEventService.publishReversiGameStream(game.id, 'ended', {
winnerId: winnerId,
game: await this.reversiGameEntityService.packDetail(game.id),
});
}
}
@bindThis
public async 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,
});
}
@bindThis
public async get(id: MiReversiGame['id']): Promise<MiReversiGame | null> {
const cached = await this.redisClient.get(`reversi:game:cache:${id}`);
if (cached != null) {
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,
};
} else {
const game = await this.reversiGamesRepository.findOneBy({ id });
if (game == null) return null;
this.cacheGame(game);
return game;
}
}
@bindThis
public async checkCrc(gameId: MiReversiGame['id'], crc32: string | number) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (crc32.toString() !== game.crc32) {
return await this.reversiGameEntityService.packDetail(game);
} else {
return null;
}
}
@bindThis
public dispose(): void {
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View file

@ -58,7 +58,7 @@ export class ApAudienceService {
};
}
if (toGroups.followers.length > 0) {
if (toGroups.followers.length > 0 || ccGroups.followers.length > 0) {
return {
visibility: 'followers',
mentionedUsers,

View file

@ -0,0 +1,117 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { ReversiGamesRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiReversiGame } from '@/models/ReversiGame.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class ReversiGameEntityService {
constructor(
@Inject(DI.reversiGamesRepository)
private reversiGamesRepository: ReversiGamesRepository,
private userEntityService: UserEntityService,
private idService: IdService,
) {
}
@bindThis
public async packDetail(
src: MiReversiGame['id'] | MiReversiGame,
me?: { id: MiUser['id'] } | null | undefined,
): Promise<Packed<'ReversiGameDetailed'>> {
const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: game.id,
createdAt: this.idService.parse(game.id).date.toISOString(),
startedAt: game.startedAt && game.startedAt.toISOString(),
endedAt: game.endedAt && game.endedAt.toISOString(),
isStarted: game.isStarted,
isEnded: game.isEnded,
form1: game.form1,
form2: game.form2,
user1Ready: game.user1Ready,
user2Ready: game.user2Ready,
user1Id: game.user1Id,
user2Id: game.user2Id,
user1: this.userEntityService.pack(game.user1Id, me),
user2: this.userEntityService.pack(game.user2Id, me),
winnerId: game.winnerId,
winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
surrenderedUserId: game.surrenderedUserId,
timeoutUserId: game.timeoutUserId,
black: game.black,
bw: game.bw,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
timeLimitForEachTurn: game.timeLimitForEachTurn,
logs: game.logs,
map: game.map,
});
}
@bindThis
public packDetailMany(
xs: MiReversiGame[],
me?: { id: MiUser['id'] } | null | undefined,
) {
return Promise.all(xs.map(x => this.packDetail(x, me)));
}
@bindThis
public async packLite(
src: MiReversiGame['id'] | MiReversiGame,
me?: { id: MiUser['id'] } | null | undefined,
): Promise<Packed<'ReversiGameLite'>> {
const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: game.id,
createdAt: this.idService.parse(game.id).date.toISOString(),
startedAt: game.startedAt && game.startedAt.toISOString(),
endedAt: game.endedAt && game.endedAt.toISOString(),
isStarted: game.isStarted,
isEnded: game.isEnded,
form1: game.form1,
form2: game.form2,
user1Ready: game.user1Ready,
user2Ready: game.user2Ready,
user1Id: game.user1Id,
user2Id: game.user2Id,
user1: this.userEntityService.pack(game.user1Id, me),
user2: this.userEntityService.pack(game.user2Id, me),
winnerId: game.winnerId,
winner: game.winnerId ? this.userEntityService.pack(game.winnerId, me) : null,
surrenderedUserId: game.surrenderedUserId,
timeoutUserId: game.timeoutUserId,
black: game.black,
bw: game.bw,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
timeLimitForEachTurn: game.timeLimitForEachTurn,
});
}
@bindThis
public packLiteMany(
xs: MiReversiGame[],
me?: { id: MiUser['id'] } | null | undefined,
) {
return Promise.all(xs.map(x => this.packLite(x, me)));
}
}