Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(game-servers): serveme.tf integration #1520

Merged
merged 15 commits into from
Mar 30, 2022
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"patreon",
"pyro",
"rcon",
"Serveme",
"snakewater",
"tftrue",
"typegoose",
Expand Down
1 change: 1 addition & 0 deletions configs/urls.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const twitchTvApiEndpoint = 'https://api.twitch.tv/helix';
export const servemeTfApiEndpoint = 'https://serveme.tf/api/reservations';
7 changes: 7 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { INestApplication } from '@nestjs/common';
garrappachc marked this conversation as resolved.
Show resolved Hide resolved

export let app: INestApplication = null;

export const setApp = (newApp: INestApplication): void => {
app = newApp;
};
1 change: 1 addition & 0 deletions src/environment-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ export const environmentSchema = object({
DISCORD_ADMIN_NOTIFICATIONS_CHANNEL: any().optional(),
TWITCH_CLIENT_ID: any().optional(),
TWITCH_CLIENT_SECRET: any().optional(),
SERVEME_TF_API_KEY: any().optional(),
});
4 changes: 4 additions & 0 deletions src/environment/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,8 @@ export class Environment {
get twitchClientSecret() {
return this.configService.get<string>('TWITCH_CLIENT_SECRET');
}

get servemeTfApiKey() {
return this.configService.get<string>('SERVEME_TF_API_KEY');
}
}
6 changes: 5 additions & 1 deletion src/game-servers/game-servers.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MongooseModule } from '@nestjs/mongoose';
import { GameServer, gameServerSchema } from './models/game-server';
import { GameServersProvidersModule } from './providers/game-servers-providers.module';
import { GameServersController } from './controllers/game-servers.controller';
import { StaticGameServerModule } from './providers/static-game-server/static-game-server.module';

const gameServerModelProvider = MongooseModule.forFeature([
{
Expand All @@ -18,9 +19,12 @@ const gameServerModelProvider = MongooseModule.forFeature([

@Module({
imports: [
forwardRef(() => GamesModule),
gameServerModelProvider,
GameServersProvidersModule.configure(),
forwardRef(() => GamesModule),

// FIXME This is a workaround for (probably) as NestJS bug, this shouldn't be needed here.
StaticGameServerModule,
],
providers: [GameServersService],
exports: [GameServersService, gameServerModelProvider],
Expand Down
18 changes: 13 additions & 5 deletions src/game-servers/providers/game-servers-providers.module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { DynamicModule, Module } from '@nestjs/common';
import { StaticGameServerModule } from './static-game-server/static-game-server.module';

const enabledProviderModules = [StaticGameServerModule];
import { ServemeTfModule } from './serveme-tf/serveme-tf.module';
import { ConfigModule } from '@nestjs/config';

@Module({})
export class GameServersProvidersModule {
static configure(): DynamicModule {
static async configure(): Promise<DynamicModule> {
await ConfigModule.envVariablesLoaded;

const enabledProviders = [StaticGameServerModule];

if (process.env.SERVEME_TF_API_KEY) {
enabledProviders.push(ServemeTfModule);
}

return {
module: GameServersProvidersModule,
imports: enabledProviderModules,
exports: enabledProviderModules,
imports: [...enabledProviders],
exports: [...enabledProviders],
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { app } from '@/app';
import { GameServer } from '@/game-servers/models/game-server';
import { createRcon } from '@/utils/create-rcon';
import { plainToClass, plainToInstance } from 'class-transformer';
import { StaticGameServer } from '../../static-game-server/models/static-game-server';
import {
isServemeTfGameServer,
ServemeTfGameServer,
} from './serveme-tf-game-server';

jest.mock('@/utils/create-rcon');
jest.mock('@/app', () => ({
app: {
get: jest.fn(),
},
}));

describe('ServemeTfGameServer', () => {
let gameServer: ServemeTfGameServer;

beforeEach(() => {
gameServer = plainToClass(ServemeTfGameServer, {
provider: 'serveme.tf',
id: 'FAKE_GAME_SERVER_ID',
createdAt: new Date(),
name: 'NewBrigade #16',
address: 'FAKE_ADDRESS',
port: '27015',
reservation: {
id: 42,
startsAt: new Date(),
endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
serverId: 128,
password: 'FAKE_SERVER_PASSWORD',
rcon: 'FAKE_RCON_PASSWORD',
logsecret: 'FAKE_LOGSECRET',
steamId: 'FAKE_STEAM_ID',
},
});
});

describe('#rcon()', () => {
it('should create an rcon connection', async () => {
await gameServer.rcon();
expect(createRcon).toHaveBeenCalledWith({
host: 'FAKE_ADDRESS',
port: 27015,
rconPassword: 'FAKE_RCON_PASSWORD',
});
});
});

describe('#getLogsecret()', () => {
it('should return the reservation logsecret', async () => {
const logsecret = await gameServer.getLogsecret();
expect(logsecret).toEqual('FAKE_LOGSECRET');
});
});

describe('#start()', () => {
const servemeTfApiService = {
waitForServerToStart: jest.fn().mockResolvedValue(null),
};

beforeEach(() => {
(app as jest.Mocked<typeof app>).get.mockReturnValue(servemeTfApiService);
});

it('should wait for the server to start', async () => {
await gameServer.start();
expect(servemeTfApiService.waitForServerToStart).toHaveBeenCalledWith(42);
});
});
});

describe('#isServemeTfGameServer()', () => {
let gameServer: GameServer;

describe('when the server is a ServemeTfGameServer instance', () => {
beforeEach(() => {
gameServer = plainToClass(ServemeTfGameServer, {
provider: 'serveme.tf',
id: 'FAKE_GAME_SERVER_ID',
createdAt: new Date(),
name: 'NewBrigade #16',
address: 'FAKE_ADDRESS',
port: '27015',
reservation: {
id: 42,
startsAt: new Date(),
endsAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
serverId: 128,
password: 'FAKE_SERVER_PASSWORD',
rcon: 'FAKE_RCON_PASSWORD',
logsecret: 'FAKE_LOGSECRET',
steamId: 'FAKE_STEAM_ID',
},
});
});

it('should return true', () => {
expect(isServemeTfGameServer(gameServer)).toBe(true);
});
});

describe('when the server is not a ServemeTfGameServer instance', () => {
beforeEach(() => {
gameServer = plainToInstance(StaticGameServer, {
provider: 'static',
id: 'FAKE_GAME_SERVER_ID',
createdAt: new Date(),
name: 'tf2pickup.org #1',
address: 'FAKE_ADDRESS',
port: '27015',
});
});

it('should return false', () => {
expect(isServemeTfGameServer(gameServer)).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { GameServer } from '@/game-servers/models/game-server';
import { createRcon } from '@/utils/create-rcon';
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Exclude, Type } from 'class-transformer';
import { Rcon } from 'rcon-client/lib';
import { toValidMumbleChannelName } from '../../static-game-server/utils/to-valid-mumble-channel-name';
import { Document } from 'mongoose';
import { app } from '@/app';
import { ServemeTfApiService } from '../services/serveme-tf-api.service';

@Schema()
class ServemeTfReservation {
@Prop()
id: number;

@Prop()
startsAt: Date;

@Prop()
endsAt: Date;

@Prop()
serverId: number;

@Prop()
password: string;

@Prop()
rcon: string;

@Prop()
logsecret: string;

@Prop()
steamId: string;
}

const servemeTfReservationSchema =
SchemaFactory.createForClass(ServemeTfReservation);

@Schema()
export class ServemeTfGameServer extends GameServer {
@Exclude({ toPlainOnly: true })
@Type(() => ServemeTfReservation)
@Prop({ type: servemeTfReservationSchema, _id: false })
reservation: ServemeTfReservation;

async rcon(): Promise<Rcon> {
return await createRcon({
host: this.address,
port: parseInt(this.port, 10),
rconPassword: this.reservation.rcon,
});
}

async voiceChannelName(): Promise<string> {
// TODO fix
return toValidMumbleChannelName(this.name);
}

async getLogsecret(): Promise<string> {
return this.reservation.logsecret;
}

async start(): Promise<this> {
const servemeTfApiService = app.get(ServemeTfApiService);
await servemeTfApiService.waitForServerToStart(this.reservation.id);
return this;
}
}

export type ServemeTfGameServerDocument = ServemeTfGameServer & Document;
export const servemeTfGameServerSchema =
SchemaFactory.createForClass(ServemeTfGameServer);

export function isServemeTfGameServer(
gameServer: GameServer,
): gameServer is ServemeTfGameServer {
return gameServer.provider === 'serveme.tf';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
GameServer,
GameServerDocument,
} from '@/game-servers/models/game-server';
import { Provider } from '@nestjs/common';
import { getModelToken } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import {
ServemeTfGameServer,
servemeTfGameServerSchema,
} from './models/serveme-tf-game-server';

export const servemeTfGameServerModelProvider: Provider = {
provide: getModelToken(ServemeTfGameServer.name),
useFactory: (gameServerModel: Model<GameServerDocument>) =>
gameServerModel.discriminator('serveme.tf', servemeTfGameServerSchema),
inject: [getModelToken(GameServer.name)],
};
16 changes: 16 additions & 0 deletions src/game-servers/providers/serveme-tf/serveme-tf.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { HttpModule } from '@nestjs/axios';
import { forwardRef, Module } from '@nestjs/common';
import { ServemeTfService } from './services/serveme-tf.service';
import { ServemeTfApiService } from './services/serveme-tf-api.service';
import { GameServersModule } from '@/game-servers/game-servers.module';
import { servemeTfGameServerModelProvider } from './serveme-tf-game-server-model.provider';

@Module({
imports: [HttpModule, forwardRef(() => GameServersModule)],
providers: [
ServemeTfService,
ServemeTfApiService,
servemeTfGameServerModelProvider,
],
})
export class ServemeTfModule {}
Loading