This commit is contained in:
syuilo
2024-01-26 14:25:00 +09:00
parent 2133d0552c
commit 67e6184a75
56 changed files with 3035 additions and 92 deletions

View File

@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Mahjong1706234054207 {
name = 'Mahjong1706234054207'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "mahjong_game" ("id" character varying(32) NOT NULL, "startedAt" TIMESTAMP WITH TIME ZONE, "endedAt" TIMESTAMP WITH TIME ZONE, "user1Id" character varying(32), "user2Id" character varying(32), "user3Id" character varying(32), "user4Id" character varying(32), "isEnded" boolean NOT NULL DEFAULT false, "winnerId" character varying(32), "timeLimitForEachTurn" smallint NOT NULL DEFAULT '90', "logs" jsonb NOT NULL DEFAULT '[]', CONSTRAINT "PK_77db54c0a9785d387e3fbbdd2f0" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_b98c78761a845b69e6540401264" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_f17b0ba519ae28f188a7915ad6f" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_64314ffd3cb59475b0d06330058" FOREIGN KEY ("user3Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "mahjong_game" ADD CONSTRAINT "FK_58a75f1ea2a810ae3986f72a0e3" FOREIGN KEY ("user4Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_58a75f1ea2a810ae3986f72a0e3"`);
await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_64314ffd3cb59475b0d06330058"`);
await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_f17b0ba519ae28f188a7915ad6f"`);
await queryRunner.query(`ALTER TABLE "mahjong_game" DROP CONSTRAINT "FK_b98c78761a845b69e6540401264"`);
await queryRunner.query(`DROP TABLE "mahjong_game"`);
}
}

View File

@@ -134,6 +134,7 @@
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"misskey-mahjong": "workspace:*",
"ms": "3.0.0-canary.1",
"nanoid": "5.0.4",
"nested-property": "4.0.0",

View File

@@ -67,6 +67,7 @@ import { FanoutTimelineService } from './FanoutTimelineService.js';
import { ChannelFollowingService } from './ChannelFollowingService.js';
import { RegistryApiService } from './RegistryApiService.js';
import { ReversiService } from './ReversiService.js';
import { MahjongService } from './MahjongService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js';
@@ -205,6 +206,7 @@ const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpo
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $MahjongService: Provider = { provide: 'MahjongService', useExisting: MahjongService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@@ -344,6 +346,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChannelFollowingService,
RegistryApiService,
ReversiService,
MahjongService,
ChartLoggerService,
FederationChart,
@@ -479,6 +482,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChannelFollowingService,
$RegistryApiService,
$ReversiService,
$MahjongService,
$ChartLoggerService,
$FederationChart,
@@ -615,6 +619,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChannelFollowingService,
RegistryApiService,
ReversiService,
MahjongService,
FederationChart,
NotesChart,
@@ -749,6 +754,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChannelFollowingService,
$RegistryApiService,
$ReversiService,
$MahjongService,
$FederationChart,
$NotesChart,

View File

@@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as Reversi from 'misskey-reversi';
import * as Mahjong from 'misskey-mahjong';
import type { MiChannel } from '@/models/Channel.js';
import type { MiUser } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
@@ -192,6 +193,28 @@ export interface ReversiGameEventTypes {
userId: MiUser['id'];
};
}
export interface MahjongRoomEventTypes {
changeReadyStates: {
user1: boolean;
user2: boolean;
user3: boolean;
user4: boolean;
};
tsumo: {
house: Mahjong.Engine.House;
tile: Mahjong.Engine.Tile;
};
dahai: {
house: Mahjong.Engine.House;
tile: Mahjong.Engine.Tile;
};
dahaiAndTsumo: {
house: Mahjong.Engine.House;
dahaiTile: Mahjong.Engine.Tile;
tsumoTile: Mahjong.Engine.Tile;
};
}
//#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義
@@ -290,6 +313,10 @@ export type GlobalEvents = {
name: `reversiGameStream:${MiReversiGame['id']}`;
payload: EventUnionFromDictionary<SerializedAll<ReversiGameEventTypes>>;
};
mahjongRoom: {
name: `mahjongRoomStream:${string}`;
payload: EventUnionFromDictionary<SerializedAll<MahjongRoomEventTypes>>;
};
};
// API event definitions
@@ -389,4 +416,9 @@ export class GlobalEventService {
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);
}
@bindThis
public publishMahjongRoomStream<K extends keyof MahjongRoomEventTypes>(roomId: string, type: K, value?: MahjongRoomEventTypes[K]): void {
this.publish(`mahjongRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value);
}
}

View File

@@ -0,0 +1,350 @@
/*
* 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 { ModuleRef } from '@nestjs/core';
import { IsNull, LessThan, MoreThan } from 'typeorm';
import * as Mahjong from 'misskey-mahjong';
import type {
MiMahjongGame,
MahjongGamesRepository,
} 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';
import { Serialized } from '@/types.js';
import { Packed } from '@/misc/json-schema.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
const PON_TIMEOUT_MS = 1000 * 10; // 10sec
const DAHAI_TIMEOUT_MS = 1000 * 30; // 30sec
type Room = {
id: string;
user1Id: MiUser['id'];
user2Id: MiUser['id'] | null;
user3Id: MiUser['id'] | null;
user4Id: MiUser['id'] | null;
user1: Packed<'UserLite'> | null;
user2: Packed<'UserLite'> | null;
user3: Packed<'UserLite'> | null;
user4: Packed<'UserLite'> | null;
user1Ai?: boolean;
user2Ai?: boolean;
user3Ai?: boolean;
user4Ai?: boolean;
user1Ready: boolean;
user2Ready: boolean;
user3Ready: boolean;
user4Ready: boolean;
user1Offline?: boolean;
user2Offline?: boolean;
user3Offline?: boolean;
user4Offline?: boolean;
isStarted?: boolean;
gameState?: Mahjong.Engine.MasterState;
};
@Injectable()
export class MahjongService implements OnApplicationShutdown, OnModuleInit {
private notificationService: NotificationService;
constructor(
private moduleRef: ModuleRef,
@Inject(DI.redis)
private redisClient: Redis.Redis,
//@Inject(DI.mahjongGamesRepository)
//private mahjongGamesRepository: MahjongGamesRepository,
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 saveRoom(room: Room) {
await this.redisClient.set(`mahjong:room:${room.id}`, JSON.stringify(room), 'EX', 60 * 30);
}
@bindThis
public async createRoom(user: MiUser): Promise<Room> {
const room: Room = {
id: this.idService.gen(),
user1Id: user.id,
user2Id: null,
user3Id: null,
user4Id: null,
user1: await this.userEntityService.pack(user),
user1Ready: false,
user2Ready: false,
user3Ready: false,
user4Ready: false,
};
await this.saveRoom(room);
return room;
}
@bindThis
public async getRoom(id: Room['id']): Promise<Room | null> {
const room = await this.redisClient.get(`mahjong:room:${id}`);
if (!room) return null;
const parsed = JSON.parse(room);
return {
...parsed,
};
}
@bindThis
public async joinRoom(roomId: Room['id'], user: MiUser): Promise<Room | null> {
const room = await this.getRoom(roomId);
if (!room) return null;
if (room.user1Id === user.id) return room;
if (room.user2Id === user.id) return room;
if (room.user3Id === user.id) return room;
if (room.user4Id === user.id) return room;
if (room.user2Id === null) {
room.user2Id = user.id;
room.user2 = await this.userEntityService.pack(user);
await this.saveRoom(room);
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 2, user: room.user2 });
return room;
}
if (room.user3Id === null) {
room.user3Id = user.id;
room.user3 = await this.userEntityService.pack(user);
await this.saveRoom(room);
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 3, user: room.user3 });
return room;
}
if (room.user4Id === null) {
room.user4Id = user.id;
room.user4 = await this.userEntityService.pack(user);
await this.saveRoom(room);
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 4, user: room.user4 });
return room;
}
return null;
}
@bindThis
public async addAi(roomId: Room['id'], user: MiUser): Promise<Room | null> {
const room = await this.getRoom(roomId);
if (!room) return null;
if (room.user1Id !== user.id) throw new Error('access denied');
if (room.user2Id == null) {
room.user2Ai = true;
room.user2Ready = true;
await this.saveRoom(room);
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 2, user: null });
return room;
}
if (room.user3Id == null) {
room.user3Ai = true;
room.user3Ready = true;
await this.saveRoom(room);
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 3, user: null });
return room;
}
if (room.user4Id == null) {
room.user4Ai = true;
room.user4Ready = true;
await this.saveRoom(room);
this.globalEventService.publishMahjongRoomStream(room.id, 'joined', { index: 4, user: null });
return room;
}
return null;
}
@bindThis
public async leaveRoom(roomId: Room['id'], user: MiUser): Promise<Room | null> {
const room = await this.getRoom(roomId);
if (!room) return null;
if (room.user1Id === user.id) {
room.user1Id = null;
room.user1 = null;
await this.saveRoom(room);
return room;
}
if (room.user2Id === user.id) {
room.user2Id = null;
room.user2 = null;
await this.saveRoom(room);
return room;
}
if (room.user3Id === user.id) {
room.user3Id = null;
room.user3 = null;
await this.saveRoom(room);
return room;
}
if (room.user4Id === user.id) {
room.user4Id = null;
room.user4 = null;
await this.saveRoom(room);
return room;
}
return null;
}
@bindThis
public async changeReadyState(roomId: Room['id'], user: MiUser, ready: boolean): Promise<void> {
const room = await this.getRoom(roomId);
if (!room) return;
if (room.user1Id === user.id) {
room.user1Ready = ready;
await this.saveRoom(room);
}
if (room.user2Id === user.id) {
room.user2Ready = ready;
await this.saveRoom(room);
}
if (room.user3Id === user.id) {
room.user3Ready = ready;
await this.saveRoom(room);
}
if (room.user4Id === user.id) {
room.user4Ready = ready;
await this.saveRoom(room);
}
this.globalEventService.publishMahjongRoomStream(room.id, 'changeReadyStates', {
user1: room.user1Ready,
user2: room.user2Ready,
user3: room.user3Ready,
user4: room.user4Ready,
});
if (room.user1Ready && room.user2Ready && room.user3Ready && room.user4Ready) {
await this.startGame(room);
}
}
@bindThis
public async startGame(room: Room) {
if (!room.user1Ready || !room.user2Ready || !room.user3Ready || !room.user4Ready) {
throw new Error('Not ready');
}
room.gameState = Mahjong.Engine.MasterGameEngine.createInitialState();
room.isStarted = true;
await this.saveRoom(room);
const packed = await this.packRoom(room);
this.globalEventService.publishMahjongRoomStream(room.id, 'started', { room: packed });
return room;
}
@bindThis
public async packRoom(room: Room, me: MiUser) {
return {
...room,
};
}
@bindThis
private async dahai(room: Room, engine: Mahjong.Engine.MasterGameEngine, user: MiUser, tile: Mahjong.Engine.Tile) {
const myHouse = user.id === room.user1Id ? engine.state.user1House : user.id === room.user2Id ? engine.state.user2House : user.id === room.user3Id ? engine.state.user3House : engine.state.user4House;
const res = engine.op_dahai(myHouse, tile);
if (res.canPonHouse) {
// TODO: 家がCPUだった場合の処理
this.redisClient.set(`mahjong:gamePonAsking:${room.id}`, '');
const waitingStartedAt = Date.now();
const interval = setInterval(async () => {
const waiting = await this.redisClient.get(`mahjong:gamePonAsking:${room.id}`);
if (waiting == null) {
clearInterval(interval);
return;
}
if (Date.now() - waitingStartedAt > PON_TIMEOUT_MS) {
await this.redisClient.del(`mahjong:gamePonAsking:${room.id}`);
clearInterval(interval);
const res = engine.op_noOnePon();
this.globalEventService.publishMahjongRoomStream(room.id, 'tsumo', { house: res.house, tile: res.tile });
return;
}
}, 2000);
this.globalEventService.publishMahjongRoomStream(room.id, 'dahai', { house: myHouse, tile });
} else {
this.globalEventService.publishMahjongRoomStream(room.id, 'dahaiAndTsumo', { house: myHouse, dahaiTile: tile, tsumoTile: res.tsumoTile });
}
}
@bindThis
public async op_dahai(roomId: MiMahjongGame['id'], user: MiUser, tile: string) {
const room = await this.getRoom(roomId);
if (room == null) return;
if (room.gameState == null) return;
await this.redisClient.del(`mahjong:gameDahaiWaiting:${room.id}`);
const engine = new Mahjong.Engine.MasterGameEngine(room.gameState);
await this.dahai(room, engine, user, tile);
}
@bindThis
public async op_pon(roomId: MiMahjongGame['id'], user: MiUser) {
const room = await this.getRoom(roomId);
if (room == null) return;
if (room.gameState == null) return;
const engine = new Mahjong.Engine.MasterGameEngine(room.gameState);
const myHouse = user.id === room.user1Id ? engine.state.user1House : user.id === room.user2Id ? engine.state.user2House : user.id === room.user3Id ? engine.state.user3House : engine.state.user4House;
const res = engine.op_pon(myHouse);
this.waitForDahai(room, user, engine);
}
@bindThis
private async waitForDahai(game: Room, user: MiUser, engine: Mahjong.Engine.MasterGameEngine) {
this.redisClient.set(`mahjong:gameDahaiWaiting:${game.id}`, '');
const waitingStartedAt = Date.now();
const interval = setInterval(async () => {
const waiting = await this.redisClient.get(`mahjong:gameDahaiWaiting:${game.id}`);
if (waiting == null) {
clearInterval(interval);
return;
}
if (Date.now() - waitingStartedAt > DAHAI_TIMEOUT_MS) {
await this.redisClient.del(`mahjong:gameDahaiWaiting:${game.id}`);
clearInterval(interval);
const house = game.user1Id === user.id ? engine.state.user1House : game.user2Id === user.id ? engine.state.user2House : game.user3Id === user.id ? engine.state.user3House : engine.state.user4House;
const handTiles = house === 'e' ? engine.state.eHandTiles : house === 's' ? engine.state.sHandTiles : house === 'w' ? engine.state.wHandTiles : engine.state.nHandTiles;
await this.dahai(game, engine, user, handTiles.at(-1));
return;
}
}, 2000);
}
@bindThis
public dispose(): void {
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -80,5 +80,6 @@ export const DI = {
userMemosRepository: Symbol('userMemosRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
mahjongGamesRepository: Symbol('mahjongGamesRepository'),
//#endregion
};

View File

@@ -40,6 +40,7 @@ import { packedSigninSchema } from '@/models/json-schema/signin.js';
import { packedRoleLiteSchema, packedRoleSchema } from '@/models/json-schema/role.js';
import { packedAdSchema } from '@/models/json-schema/ad.js';
import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js';
import { packedMahjongRoomDetailedSchema } from '@/models/json-schema/mahjong-room.js';
export const refs = {
UserLite: packedUserLiteSchema,
@@ -81,6 +82,7 @@ export const refs = {
Role: packedRoleSchema,
ReversiGameLite: packedReversiGameLiteSchema,
ReversiGameDetailed: packedReversiGameDetailedSchema,
MahjongRoomDetailed: packedMahjongRoomDetailedSchema,
};
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;

View File

@@ -0,0 +1,89 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('mahjong_game')
export class MiMahjongGame {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
nullable: true,
})
public startedAt: Date | null;
@Column('timestamp with time zone', {
nullable: true,
})
public endedAt: Date | null;
@Column({
...id(),
nullable: true,
})
public user1Id: MiUser['id'] | null;
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user1: MiUser | null;
@Column({
...id(),
nullable: true,
})
public user2Id: MiUser['id'] | null;
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user2: MiUser | null;
@Column({
...id(),
nullable: true,
})
public user3Id: MiUser['id'] | null;
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user3: MiUser | null;
@Column({
...id(),
nullable: true,
})
public user4Id: MiUser['id'] | null;
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user4: MiUser | null;
@Column('boolean', {
default: false,
})
public isEnded: boolean;
@Column({
...id(),
nullable: true,
})
public winnerId: MiUser['id'] | null;
// in sec
@Column('smallint', {
default: 90,
})
public timeLimitForEachTurn: number;
@Column('jsonb', {
default: [],
})
public logs: number[][];
}

View File

@@ -5,7 +5,7 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame } from './_.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame, MiMahjongGame } from './_.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@@ -411,6 +411,12 @@ const $reversiGamesRepository: Provider = {
inject: [DI.db],
};
const $mahjongGamesRepository: Provider = {
provide: DI.mahjongGamesRepository,
useFactory: (db: DataSource) => db.getRepository(MiMahjongGame),
inject: [DI.db],
};
@Module({
imports: [
],
@@ -482,6 +488,7 @@ const $reversiGamesRepository: Provider = {
$userMemosRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
$mahjongGamesRepository,
],
exports: [
$usersRepository,
@@ -551,6 +558,7 @@ const $reversiGamesRepository: Provider = {
$userMemosRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
$mahjongGamesRepository,
],
})
export class RepositoryModule {}

View File

@@ -70,6 +70,7 @@ import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiMahjongGame } from '@/models/MahjongGame.js';
import type { Repository } from 'typeorm';
@@ -141,6 +142,7 @@ export {
MiUserMemo,
MiBubbleGameRecord,
MiReversiGame,
MiMahjongGame,
};
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport>;
@@ -210,3 +212,4 @@ export type FlashLikesRepository = Repository<MiFlashLike>;
export type UserMemoRepository = Repository<MiUserMemo>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame>;
export type MahjongGamesRepository = Repository<MiMahjongGame>;

View File

@@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedMahjongRoomDetailedSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
startedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
endedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
isStarted: {
type: 'boolean',
optional: false, nullable: false,
},
isEnded: {
type: 'boolean',
optional: false, nullable: false,
},
user1Id: {
type: 'string',
optional: false, nullable: null,
format: 'id',
},
user2Id: {
type: 'string',
optional: false, nullable: null,
format: 'id',
},
user3Id: {
type: 'string',
optional: false, nullable: null,
format: 'id',
},
user4Id: {
type: 'string',
optional: false, nullable: null,
format: 'id',
},
user1: {
type: 'object',
optional: false, nullable: null,
ref: 'User',
},
user2: {
type: 'object',
optional: false, nullable: null,
ref: 'User',
},
user3: {
type: 'object',
optional: false, nullable: null,
ref: 'User',
},
user4: {
type: 'object',
optional: false, nullable: null,
ref: 'User',
},
user1Ai: {
type: 'boolean',
optional: false, nullable: false,
},
user2Ai: {
type: 'boolean',
optional: false, nullable: false,
},
user3Ai: {
type: 'boolean',
optional: false, nullable: false,
},
user4Ai: {
type: 'boolean',
optional: false, nullable: false,
},
user1Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user2Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user3Ready: {
type: 'boolean',
optional: false, nullable: false,
},
user4Ready: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;

View File

@@ -78,6 +78,7 @@ import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserMemo } from '@/models/UserMemo.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiMahjongGame } from '@/models/MahjongGame.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
@@ -194,6 +195,7 @@ export const entities = [
MiUserMemo,
MiBubbleGameRecord,
MiReversiGame,
MiMahjongGame,
...charts,
];

View File

@@ -45,6 +45,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { MahjongRoomChannelService } from './api/stream/channels/mahjong-room.js';
@Module({
imports: [
@@ -82,6 +83,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
RoleTimelineChannelService,
ReversiChannelService,
ReversiGameChannelService,
MahjongRoomChannelService,
HomeTimelineChannelService,
HybridTimelineChannelService,
LocalTimelineChannelService,

View File

@@ -373,6 +373,9 @@ 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 * as ep___mahjong_createRoom from './endpoints/mahjong/create-room.js';
import * as ep___mahjong_joinRoom from './endpoints/mahjong/join-room.js';
import * as ep___mahjong_showRoom from './endpoints/mahjong/show-room.js';
import { GetterService } from './GetterService.js';
import { ApiLoggerService } from './ApiLoggerService.js';
import type { Provider } from '@nestjs/common';
@@ -744,6 +747,9 @@ const $reversi_invitations: Provider = { provide: 'ep:reversi/invitations', useC
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 };
const $mahjong_createRoom: Provider = { provide: 'ep:mahjong/create-room', useClass: ep___mahjong_createRoom.default };
const $mahjong_joinRoom: Provider = { provide: 'ep:mahjong/join-room', useClass: ep___mahjong_joinRoom.default };
const $mahjong_showRoom: Provider = { provide: 'ep:mahjong/show-room', useClass: ep___mahjong_showRoom.default };
@Module({
imports: [
@@ -1119,6 +1125,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$reversi_showGame,
$reversi_surrender,
$reversi_verify,
$mahjong_createRoom,
$mahjong_joinRoom,
$mahjong_showRoom,
],
exports: [
$admin_meta,
@@ -1485,6 +1494,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$reversi_showGame,
$reversi_surrender,
$reversi_verify,
$mahjong_createRoom,
$mahjong_joinRoom,
$mahjong_showRoom,
],
})
export class EndpointsModule {}

View File

@@ -374,6 +374,9 @@ 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 * as ep___mahjong_createRoom from './endpoints/mahjong/create-room.js';
import * as ep___mahjong_joinRoom from './endpoints/mahjong/join-room.js';
import * as ep___mahjong_showRoom from './endpoints/mahjong/show-room.js';
const eps = [
['admin/meta', ep___admin_meta],
@@ -743,6 +746,9 @@ const eps = [
['reversi/show-game', ep___reversi_showGame],
['reversi/surrender', ep___reversi_surrender],
['reversi/verify', ep___reversi_verify],
['mahjong/create-room', ep___mahjong_createRoom],
['mahjong/join-room', ep___mahjong_joinRoom],
['mahjong/show-room', ep___mahjong_showRoom],
];
interface IEndpointMetaBase {

View File

@@ -0,0 +1,44 @@
/*
* 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';
export const meta = {
requireCredential: true,
kind: 'write:account',
errors: {
},
res: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private reversiService: ReversiService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.userId) {
await this.reversiService.matchSpecificUserCancel(me, ps.userId);
return;
} else {
await this.reversiService.matchAnyUserCancel(me);
}
});
}
}

View File

@@ -0,0 +1,44 @@
/*
* 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 { MahjongService } from '@/core/MahjongService.js';
import { ApiError } from '../../error.js';
export const meta = {
requireCredential: true,
kind: 'write:account',
errors: {
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'MahjongRoomDetailed',
},
} as const;
export const paramDef = {
type: 'object',
properties: {
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private mahjongService: MahjongService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.mahjongService.createRoom(me);
return await this.mahjongService.packRoom(room, me);
});
}
}

View File

@@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { DI } from '@/di-symbols.js';
import type { ReversiGamesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
export const meta = {
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: { ref: 'ReversiGameLite' },
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
my: { type: 'boolean', default: false },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.reversiGamesRepository)
private reversiGamesRepository: ReversiGamesRepository,
private reversiGameEntityService: ReversiGameEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId)
.innerJoinAndSelect('game.user1', 'user1')
.innerJoinAndSelect('game.user2', 'user2');
if (ps.my && me) {
query.andWhere(new Brackets(qb => {
qb
.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();
return await this.reversiGameEntityService.packLiteMany(games);
});
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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 { MahjongService } from '@/core/MahjongService.js';
import { ApiError } from '../../error.js';
export const meta = {
requireCredential: true,
kind: 'write:account',
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: '370e42b0-2a67-4306-9328-51c5f568f110',
},
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'MahjongRoomDetailed',
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private mahjongService: MahjongService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.mahjongService.getRoom(ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
await this.mahjongService.joinRoom(room.id, me);
return await this.mahjongService.packRoom(room, me);
});
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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 { MahjongService } from '@/core/MahjongService.js';
import { ApiError } from '../../error.js';
export const meta = {
requireCredential: true,
kind: 'read:account',
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'd77df68f-06f3-492b-9078-e6f72f4acf23',
},
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'MahjongRoomDetailed',
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private mahjongService: MahjongService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.mahjongService.getRoom(ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
return await this.mahjongService.packRoom(room, me);
});
}
}

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

@@ -21,6 +21,7 @@ import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
import { ReversiChannelService } from './channels/reversi.js';
import { ReversiGameChannelService } from './channels/reversi-game.js';
import { MahjongRoomChannelService } from './channels/mahjong-room.js';
import { type MiChannelService } from './channel.js';
@Injectable()
@@ -42,6 +43,7 @@ export class ChannelsService {
private adminChannelService: AdminChannelService,
private reversiChannelService: ReversiChannelService,
private reversiGameChannelService: ReversiGameChannelService,
private mahjongRoomChannelService: MahjongRoomChannelService,
) {
}
@@ -64,6 +66,7 @@ export class ChannelsService {
case 'admin': return this.adminChannelService;
case 'reversi': return this.reversiChannelService;
case 'reversiGame': return this.reversiGameChannelService;
case 'mahjongRoom': return this.mahjongRoomChannelService;
default:
throw new Error(`no such channel: ${name}`);

View File

@@ -0,0 +1,107 @@
/*
* 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 { bindThis } from '@/decorators.js';
import { MahjongService } from '@/core/MahjongService.js';
import Channel, { type MiChannelService } from '../channel.js';
class MahjongRoomChannel extends Channel {
public readonly chName = 'mahjongRoom';
public static shouldShare = false;
public static requireCredential = true as const;
public static kind = 'read:account';
private roomId: string | null = null;
constructor(
private mahjongService: MahjongService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
}
@bindThis
public async init(params: any) {
this.roomId = params.roomId as string;
this.subscriber.on(`mahjongRoomStream:${this.roomId}`, this.send);
}
@bindThis
public onMessage(type: string, body: any) {
switch (type) {
case 'ready': this.ready(body); break;
case 'updateSettings': this.updateSettings(body.key, body.value); break;
case 'addAi': this.addAi(); break;
case 'putStone': this.putStone(body.pos, body.id); break;
case 'claimTimeIsUp': this.claimTimeIsUp(); break;
}
}
@bindThis
private async updateSettings(key: string, value: any) {
if (this.user == null) return;
this.mahjongService.updateSettings(this.roomId!, this.user, key, value);
}
@bindThis
private async ready(ready: boolean) {
if (this.user == null) return;
this.mahjongService.changeReadyState(this.roomId!, this.user, ready);
}
@bindThis
private async addAi() {
if (this.user == null) return;
this.mahjongService.addAi(this.roomId!, this.user);
}
@bindThis
private async putStone(pos: number, id: string) {
if (this.user == null) return;
this.mahjongService.putStoneToRoom(this.roomId!, this.user, pos, id);
}
@bindThis
private async claimTimeIsUp() {
if (this.user == null) return;
this.mahjongService.checkTimeout(this.roomId!);
}
@bindThis
public dispose() {
// Unsubscribe events
this.subscriber.off(`mahjongRoomStream:${this.roomId}`, this.send);
}
}
@Injectable()
export class MahjongRoomChannelService implements MiChannelService<true> {
public readonly shouldShare = MahjongRoomChannel.shouldShare;
public readonly requireCredential = MahjongRoomChannel.requireCredential;
public readonly kind = MahjongRoomChannel.kind;
constructor(
private mahjongService: MahjongService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): MahjongRoomChannel {
return new MahjongRoomChannel(
this.mahjongService,
id,
connection,
);
}
}