From 7873a789ce019268deb45b32d844face0c3768d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Sun, 22 Dec 2024 16:46:47 +0100 Subject: [PATCH] feat: serveme.tf integration (#124) --- package.json | 2 + pnpm-lock.yaml | 19 +++++ public/js/main.js | 1 + src/admin/game-servers/index.ts | 56 ++++++++++--- .../views/html/game-servers.page.tsx | 12 +++ .../html/serveme-tf-preferred-region.tsx | 67 +++++++++++++++ .../views/html/serveme-tf-status.tsx | 16 ++++ src/admin/plugins/standard-admin-page.ts | 4 +- .../models/configuration-entry.model.ts | 2 +- src/database/models/game.model.ts | 9 +++ src/environment.ts | 4 + src/game-servers/assign.ts | 29 +++---- src/games/plugins/sync-clients.ts | 2 +- src/games/rcon/configure.ts | 49 ++++++----- src/games/rcon/with-rcon.ts | 25 +++--- src/html/layout.tsx | 2 +- src/main.ts | 1 + src/serveme-tf/assign.ts | 48 +++++++++++ src/serveme-tf/cache.ts | 18 +++++ src/serveme-tf/client.ts | 21 +++++ src/serveme-tf/index.ts | 19 +++++ src/serveme-tf/list-regions.ts | 10 +++ src/serveme-tf/pick-server.test.ts | 81 +++++++++++++++++++ src/serveme-tf/pick-server.ts | 38 +++++++++ src/serveme-tf/plugins/end-reservation.ts | 39 +++++++++ src/serveme-tf/wait-for-start.ts | 9 +++ src/static-game-servers/assign.ts | 20 ++++- src/tasks/tasks.ts | 4 + tests/20-game/02-free-players.spec.ts | 22 +++-- 29 files changed, 555 insertions(+), 74 deletions(-) create mode 100644 src/admin/game-servers/views/html/serveme-tf-preferred-region.tsx create mode 100644 src/admin/game-servers/views/html/serveme-tf-status.tsx create mode 100644 src/serveme-tf/assign.ts create mode 100644 src/serveme-tf/cache.ts create mode 100644 src/serveme-tf/client.ts create mode 100644 src/serveme-tf/index.ts create mode 100644 src/serveme-tf/list-regions.ts create mode 100644 src/serveme-tf/pick-server.test.ts create mode 100644 src/serveme-tf/pick-server.ts create mode 100644 src/serveme-tf/plugins/end-reservation.ts create mode 100644 src/serveme-tf/wait-for-start.ts diff --git a/package.json b/package.json index 41cb5038..64c16591 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,10 @@ "@kitajs/html": "4.2.6", "@kitajs/ts-html-plugin": "4.1.1", "@tailwindcss/typography": "0.5.15", + "@tf2pickup-org/serveme-tf-client": "0.1.2", "async-mutex": "0.5.0", "autoprefixer": "10.4.20", + "country-flag-icons": "1.5.13", "croner": "9.0.0", "cssnano": "7.0.6", "date-fns": "4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1044541..664f3a00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,12 +47,18 @@ importers: '@tailwindcss/typography': specifier: 0.5.15 version: 0.5.15(tailwindcss@3.4.17) + '@tf2pickup-org/serveme-tf-client': + specifier: 0.1.2 + version: 0.1.2 async-mutex: specifier: 0.5.0 version: 0.5.0 autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.49) + country-flag-icons: + specifier: 1.5.13 + version: 1.5.13 croner: specifier: 9.0.0 version: 9.0.0 @@ -810,6 +816,9 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' + '@tf2pickup-org/serveme-tf-client@0.1.2': + resolution: {integrity: sha512-cVEEbUwfc9ooVHLsjirXzHgWfzwjJ7seXy6blzxgKv2KpcB59iGDyzQq80Hu4b6C6OXOQctQCndfZqupKTednQ==} + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -1204,6 +1213,9 @@ packages: typescript: optional: true + country-flag-icons@1.5.13: + resolution: {integrity: sha512-4JwHNqaKZ19doQoNcBjsoYA+I7NqCH/mC/6f5cBWvdKzcK5TMmzLpq3Z/syVHMHJuDGFwJ+rPpGizvrqJybJow==} + cp-file@10.0.0: resolution: {integrity: sha512-vy2Vi1r2epK5WqxOLnskeKeZkdZvTKfFZQCplE3XWsP+SUJyd5XAUFC9lFgTjjXJF2GMne/UML14iEmkAaDfFg==} engines: {node: '>=14.16'} @@ -3587,6 +3599,11 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.17 + '@tf2pickup-org/serveme-tf-client@0.1.2': + dependencies: + date-fns: 4.1.0 + generate-password: 1.7.1 + '@trysound/sax@0.2.0': {} '@tsconfig/strictest@2.0.5': {} @@ -4021,6 +4038,8 @@ snapshots: optionalDependencies: typescript: 5.7.2 + country-flag-icons@1.5.13: {} + cp-file@10.0.0: dependencies: graceful-fs: 4.2.11 diff --git a/public/js/main.js b/public/js/main.js index b9a60aaf..300c03e1 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -2,6 +2,7 @@ import './htmx.js' import 'https://unpkg.com/htmx-ext-head-support@2.0.2/head-support.js' import 'https://unpkg.com/htmx-ext-ws@2.0.1/ws.js' import 'https://unpkg.com/hyperscript.org@0.9.13/dist/_hyperscript.min.js' +import 'https://unpkg.com/htmx.org@1.9.12/dist/ext/remove-me.js' import './countdown.js' import './fade-scroll.js' diff --git a/src/admin/game-servers/index.ts b/src/admin/game-servers/index.ts index a008a982..02af0e2c 100644 --- a/src/admin/game-servers/index.ts +++ b/src/admin/game-servers/index.ts @@ -1,11 +1,49 @@ -import { z } from 'zod' -import { standardAdminPage } from '../plugins/standard-admin-page' +import fp from 'fastify-plugin' import { GameServersPage } from './views/html/game-servers.page' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { PlayerRole } from '../../database/models/player.model' +import { z } from 'zod' +import { configuration } from '../../configuration' +import { RegionList } from './views/html/serveme-tf-preferred-region' -export default standardAdminPage({ - path: '/admin/game-servers', - page: async user => await GameServersPage({ user }), - bodySchema: z.object({}), - // eslint-disable-next-line @typescript-eslint/no-empty-function - save: async () => {}, -}) +export default fp( + // eslint-disable-next-line @typescript-eslint/require-await + async app => { + app + .withTypeProvider() + .get( + '/admin/game-servers', + { + config: { + authorize: [PlayerRole.admin], + }, + }, + async (request, reply) => { + return reply.status(200).html(GameServersPage({ user: request.user! })) + }, + ) + .put( + '/admin/game-servers/serveme-tf/preferred-region', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + body: z.object({ + servemeTfPreferredRegion: z.string().transform(val => (val === 'none' ? null : val)), + }), + }, + }, + async (request, reply) => { + await configuration.set( + 'serveme_tf.preferred_region', + request.body.servemeTfPreferredRegion, + ) + return reply.status(200).html(RegionList({ saveResult: { success: true } })) + }, + ) + }, + { + name: `admin - game servers`, + }, +) diff --git a/src/admin/game-servers/views/html/game-servers.page.tsx b/src/admin/game-servers/views/html/game-servers.page.tsx index 9fb88049..e34b0e88 100644 --- a/src/admin/game-servers/views/html/game-servers.page.tsx +++ b/src/admin/game-servers/views/html/game-servers.page.tsx @@ -1,5 +1,8 @@ import type { User } from '../../../../auth/types/user' +import { servemeTf } from '../../../../serveme-tf' import { Admin } from '../../../views/html/admin' +import { ServemeTfPreferredRegion } from './serveme-tf-preferred-region' +import { ServemeTfStatus } from './serveme-tf-status' import { StaticGameServerList } from './static-game-server-list' export async function GameServersPage(props: { user: User }) { @@ -9,6 +12,15 @@ export async function GameServersPage(props: { user: User }) {

Static servers

+ +
+
+

serveme.tf

+ +
+ + {servemeTf.isEnabled && } +
) } diff --git a/src/admin/game-servers/views/html/serveme-tf-preferred-region.tsx b/src/admin/game-servers/views/html/serveme-tf-preferred-region.tsx new file mode 100644 index 00000000..168b12c5 --- /dev/null +++ b/src/admin/game-servers/views/html/serveme-tf-preferred-region.tsx @@ -0,0 +1,67 @@ +import { memoize } from 'lodash-es' +import { servemeTf } from '../../../../serveme-tf' +import getUnicodeFlagIcon from 'country-flag-icons/unicode' +import { configuration } from '../../../../configuration' +import { IconLoader3 } from '../../../../html/components/icons' + +export function ServemeTfPreferredRegion() { + return ( +

+

+
+ +
+
+
+ +
+ + If a game server from the preferred region is not available, another one will be picked + up instead. + +
+
+

+ ) +} + +const getRegions = memoize(async () => await servemeTf.listRegions()) + +type SaveResult = { success: true } + +export async function RegionList(props?: { saveResult?: SaveResult }) { + const regions = await getRegions() + const selected = await configuration.get('serveme_tf.preferred_region') + + return ( +
+ +
+ ) +} diff --git a/src/admin/game-servers/views/html/serveme-tf-status.tsx b/src/admin/game-servers/views/html/serveme-tf-status.tsx new file mode 100644 index 00000000..b0c66843 --- /dev/null +++ b/src/admin/game-servers/views/html/serveme-tf-status.tsx @@ -0,0 +1,16 @@ +import { IconCheck, IconX } from '../../../../html/components/icons' +import { servemeTf } from '../../../../serveme-tf' + +export function ServemeTfStatus() { + return servemeTf.isEnabled ? ( + + + enabled + + ) : ( + + + disabled + + ) +} diff --git a/src/admin/plugins/standard-admin-page.ts b/src/admin/plugins/standard-admin-page.ts index 9c9b3015..019de63e 100644 --- a/src/admin/plugins/standard-admin-page.ts +++ b/src/admin/plugins/standard-admin-page.ts @@ -31,7 +31,7 @@ export function standardAdminPage({ }, }, async (request, reply) => { - reply.status(200).html(await page(request.user!)) + return reply.status(200).html(page(request.user!)) }, ) .post( @@ -47,7 +47,7 @@ export function standardAdminPage({ async (request, reply) => { await save(request.body as Input) requestContext.set('messages', { success: ['Configuration saved'] }) - reply.status(200).html(await page(request.user!)) + return reply.status(200).html(page(request.user!)) }, ) }, diff --git a/src/database/models/configuration-entry.model.ts b/src/database/models/configuration-entry.model.ts index 4f960474..e72364bd 100644 --- a/src/database/models/configuration-entry.model.ts +++ b/src/database/models/configuration-entry.model.ts @@ -136,7 +136,7 @@ export const configurationSchema = z.discriminatedUnion('key', [ value: z.string().nullable().default(null), }), z.object({ - key: z.literal('serveme.tf.ban_gameservers'), + key: z.literal('serveme_tf.ban_gameservers'), value: z.array(z.string()).default([]), }), z.object({ diff --git a/src/database/models/game.model.ts b/src/database/models/game.model.ts index 21cc21dd..09796fd7 100644 --- a/src/database/models/game.model.ts +++ b/src/database/models/game.model.ts @@ -36,6 +36,15 @@ export interface GameServer { name: string address: string port: string + + // if logSecret is undefined, a random one will be assigned automatically + logSecret?: string + + rcon: { + address: string + port: string + password: string + } } export interface GameModel { diff --git a/src/environment.ts b/src/environment.ts index a15b467e..dd268ab4 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import dotenv from 'dotenv' +import { KnownEndpoint } from '@tf2pickup-org/serveme-tf-client' dotenv.config() @@ -19,6 +20,9 @@ const environmentSchema = z.object({ GAME_SERVER_SECRET: z.string(), THUMBNAIL_SERVICE_URL: z.string().url(), + SERVEME_TF_API_ENDPOINT: z.string().default(KnownEndpoint.europe), + SERVEME_TF_API_KEY: z.string().optional(), + TWITCH_CLIENT_ID: z.string().optional(), TWITCH_CLIENT_SECRET: z.string().optional(), }) diff --git a/src/game-servers/assign.ts b/src/game-servers/assign.ts index 67cd5397..335adc24 100644 --- a/src/game-servers/assign.ts +++ b/src/game-servers/assign.ts @@ -1,39 +1,40 @@ -import { GameServerProvider, type GameModel } from '../database/models/game.model' +import { type GameModel, type GameServer } from '../database/models/game.model' import { games } from '../games' import { staticGameServers } from '../static-game-servers' import { events } from '../events' import { GameEventType } from '../database/models/game-event.model' import { logger } from '../logger' import { Mutex } from 'async-mutex' +import { servemeTf } from '../serveme-tf' const mutex = new Mutex() export async function assign(game: GameModel) { await mutex.runExclusive(async () => { - const staticGameServer = await staticGameServers.assign(game) - if (!staticGameServer) { - throw new Error(`no free servers available for game ${game.number}`) - } + const gameServer = await assignFirstFree(game) game = await games.update(game.number, { $set: { - gameServer: { - id: staticGameServer.id, - name: staticGameServer.name, - address: staticGameServer.address, - port: staticGameServer.port, - provider: GameServerProvider.static, - }, + gameServer, }, $push: { events: { event: GameEventType.gameServerAssigned, at: new Date(), - gameServerName: staticGameServer.name, + gameServerName: gameServer.name, }, }, }) - logger.info({ game }, `game ${game.number} assigned to game server ${staticGameServer.name}`) + logger.info({ game }, `game ${game.number} assigned to game server ${gameServer.name}`) events.emit('game:gameServerAssigned', { game }) }) } + +function assignFirstFree(game: GameModel): Promise { + return staticGameServers + .assign(game) + .catch(() => servemeTf.assign(game)) + .catch(() => { + throw new Error(`no free servers available for game ${game.number}`) + }) +} diff --git a/src/games/plugins/sync-clients.ts b/src/games/plugins/sync-clients.ts index 44553293..64657597 100644 --- a/src/games/plugins/sync-clients.ts +++ b/src/games/plugins/sync-clients.ts @@ -102,7 +102,7 @@ export default fp(async app => { events.on( 'game:playerConnectionStatusUpdated', safe(async ({ game, player, playerConnectionStatus }) => { - app.gateway.broadcast( + app.gateway.to({ url: `/games/${game.number}` }).send( async () => await PlayerConnectionStatusIndicator({ steamId: player, diff --git a/src/games/rcon/configure.ts b/src/games/rcon/configure.ts index a46c5763..f7c992c6 100644 --- a/src/games/rcon/configure.ts +++ b/src/games/rcon/configure.ts @@ -3,7 +3,7 @@ import { deburr } from 'lodash-es' import { configuration } from '../../configuration' import { collections } from '../../database/collections' import { GameEventType } from '../../database/models/game-event.model' -import { type GameModel, GameState } from '../../database/models/game.model' +import { type GameModel, GameServerProvider, GameState } from '../../database/models/game.model' import { environment } from '../../environment' import { logger } from '../../logger' import { LogsTfUploadMethod } from '../../shared/types/logs-tf-upload-method' @@ -22,12 +22,15 @@ import { tvPort, tvPassword, svLogsecret, + execConfig, } from './commands' import { update } from '../update' import { extractConVarValue } from '../extract-con-var-value' import { generate } from 'generate-password' import { events } from '../../events' import { withRcon } from './with-rcon' +import { servemeTf } from '../../serveme-tf' +import type { ReservationId } from '@tf2pickup-org/serveme-tf-client' export async function configure(game: GameModel, options: { signal?: AbortSignal } = {}) { if (game.gameServer === undefined) { @@ -36,19 +39,32 @@ export async function configure(game: GameModel, options: { signal?: AbortSignal logger.info({ game }, `configuring game #${game.number}...`) const { signal } = options + if (game.gameServer.provider === GameServerProvider.servemeTf) { + await servemeTf.waitForStart(Number(game.gameServer.id) as ReservationId) + } + + if (signal?.aborted) { + throw new Error(`${signal.reason}`) + } + const password = generateGameserverPassword() const configLines = await compileConfig(game, password) - return await withRcon(game, async ({ rcon, gameServer }) => { - const logSecret = generate({ - length: 16, - numbers: true, - symbols: false, - lowercase: false, - uppercase: false, - }) + return await withRcon(game, async ({ rcon }) => { + let logSecret: string + if (!game.gameServer!.logSecret) { + logSecret = generate({ + length: 16, + numbers: true, + symbols: false, + lowercase: false, + uppercase: false, + }) + await rcon.send(svLogsecret(logSecret)) + } else { + logSecret = game.gameServer!.logSecret + } - await rcon.send(svLogsecret(logSecret)) if (signal?.aborted) { throw new Error(`${signal.reason}`) } @@ -79,14 +95,13 @@ export async function configure(game: GameModel, options: { signal?: AbortSignal logger.info(game, `game ${game.number} configured`) const connectString = makeConnectString({ - address: gameServer.address, - port: gameServer.port, + ...game.gameServer!, password, }) logger.info(game, `connect string: ${connectString}`) const stvConnectString = makeConnectString({ - address: gameServer.address, + address: game.gameServer!.address, port: extractConVarValue(await rcon.send(tvPort())) ?? 27020, password: extractConVarValue(await rcon.send(tvPassword())), }) @@ -121,16 +136,12 @@ async function compileConfig(game: GameModel, password: string): Promise { const map = await collections.maps.findOne({ name: game.map }) - if (map !== null) { - return map.execConfig ?? [] - } else { - return [] - } + return map?.execConfig ? execConfig(map.execConfig) : [] })(), ) .concat( diff --git a/src/games/rcon/with-rcon.ts b/src/games/rcon/with-rcon.ts index 20641be7..56d735da 100644 --- a/src/games/rcon/with-rcon.ts +++ b/src/games/rcon/with-rcon.ts @@ -1,41 +1,34 @@ import { Rcon } from 'rcon-client' -import { collections } from '../../database/collections' -import { type GameModel, GameServerProvider } from '../../database/models/game.model' +import { type GameModel } from '../../database/models/game.model' import { assertIsError } from '../../utils/assert-is-error' import { logger } from '../../logger' -import type { StaticGameServerModel } from '../../database/models/static-game-server.model' export async function withRcon( game: GameModel, - callback: (args: { rcon: Rcon; gameServer: StaticGameServerModel }) => Promise, + callback: (args: { rcon: Rcon }) => Promise, ): Promise { if (game.gameServer === undefined) { throw new Error(`gameServer is undefined`) } - if (game.gameServer.provider !== GameServerProvider.static) { - throw new Error(`gameServer provider not supported`) - } logger.trace({ game }, `withRcon()`) - const gameServer = await collections.staticGameServers.findOne({ id: game.gameServer.id }) - if (gameServer === null) { - throw new Error(`gameServer not found`) - } - let rcon: Rcon | undefined = undefined + const { address, port, password } = game.gameServer.rcon + try { rcon = await Rcon.connect({ - host: gameServer.internalIpAddress, - port: Number(gameServer.port), - password: gameServer.rconPassword, + host: address, + port: Number(port), + password: password, + timeout: 30000, }) rcon.on('error', error => { assertIsError(error) logger.error(error, `game #${game.number}: rcon error`) }) - return await callback({ rcon, gameServer }) + return await callback({ rcon }) } finally { await rcon?.end() } diff --git a/src/html/layout.tsx b/src/html/layout.tsx index 0d5c1a76..30f287af 100644 --- a/src/html/layout.tsx +++ b/src/html/layout.tsx @@ -48,7 +48,7 @@ export async function Layout( {title} - + {body} diff --git a/src/main.ts b/src/main.ts index 66116dd8..661980cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,5 +51,6 @@ await app.register((await import('./twitch-tv')).default) await app.register((await import('./admin')).default) await app.register((await import('./hall-of-game')).default) await app.register((await import('./pre-ready')).default) +await app.register((await import('./serveme-tf')).default) await app.listen({ host: environment.APP_HOST, port: environment.APP_PORT }) diff --git a/src/serveme-tf/assign.ts b/src/serveme-tf/assign.ts new file mode 100644 index 00000000..5dff7bca --- /dev/null +++ b/src/serveme-tf/assign.ts @@ -0,0 +1,48 @@ +import { GameServerProvider, type GameModel, type GameServer } from '../database/models/game.model' +import { client } from './client' +import { logger } from '../logger' +import { pickServer } from './pick-server' + +export async function assign(game: GameModel): Promise { + if (!client) { + throw new Error(`serveme.tf disabled`) + } + + const { servers } = await client.findOptions() + logger.debug({ servers }, 'serveme.tf servers listed') + + const serverId = await pickServer(servers) + logger.debug({ serverId }, 'serveme.tf server selected') + + const reservation = await client.create({ + serverId, + enableDemosTf: true, + enablePlugins: true, + firstMap: game.map, + }) + logger.info( + { + reservation: { + id: reservation.id, + name: reservation.server.name, + ipAndPort: reservation.server.ip_and_port, + }, + }, + `reservation created`, + ) + + return { + provider: GameServerProvider.servemeTf, + id: reservation.id.toString(), + name: reservation.server.name, + address: reservation.server.ip, + port: reservation.server.port, + logSecret: reservation.logSecret, + + rcon: { + address: reservation.server.ip, + port: reservation.server.port, + password: reservation.rcon, + }, + } +} diff --git a/src/serveme-tf/cache.ts b/src/serveme-tf/cache.ts new file mode 100644 index 00000000..55ccadc2 --- /dev/null +++ b/src/serveme-tf/cache.ts @@ -0,0 +1,18 @@ +import { Reservation, type ReservationId } from '@tf2pickup-org/serveme-tf-client' +import { client } from './client' + +const cache = new Map() + +export async function get(id: ReservationId): Promise { + if (cache.has(id)) { + return cache.get(id)! + } + + if (!client) { + throw new Error(`serveme.tf disabled`) + } + + const r = await client.fetch(id) + cache.set(id, r) + return r +} diff --git a/src/serveme-tf/client.ts b/src/serveme-tf/client.ts new file mode 100644 index 00000000..b14fcc13 --- /dev/null +++ b/src/serveme-tf/client.ts @@ -0,0 +1,21 @@ +import { Client } from '@tf2pickup-org/serveme-tf-client' +import { environment } from '../environment' +import { logger } from '../logger' + +export const client = await initializeClient() + +async function initializeClient(): Promise { + if (environment.SERVEME_TF_API_KEY) { + logger.info( + { servemeTfApiEndpoint: environment.SERVEME_TF_API_ENDPOINT }, + 'serveme.tf integration enabled', + ) + return new Client({ + endpoint: environment.SERVEME_TF_API_ENDPOINT, + apiKey: environment.SERVEME_TF_API_KEY, + }) + } else { + logger.info('serveme.tf integration disabled') + return null + } +} diff --git a/src/serveme-tf/index.ts b/src/serveme-tf/index.ts new file mode 100644 index 00000000..b32b88ec --- /dev/null +++ b/src/serveme-tf/index.ts @@ -0,0 +1,19 @@ +import fp from 'fastify-plugin' +import { assign } from './assign' +import { client } from './client' +import { listRegions } from './list-regions' +import { resolve } from 'node:path' +import { waitForStart } from './wait-for-start' + +export const servemeTf = { + assign, + isEnabled: client !== null, + listRegions, + waitForStart, +} as const + +export default fp(async app => { + await app.register((await import('@fastify/autoload')).default, { + dir: resolve(import.meta.dirname, 'plugins'), + }) +}) diff --git a/src/serveme-tf/list-regions.ts b/src/serveme-tf/list-regions.ts new file mode 100644 index 00000000..88469f55 --- /dev/null +++ b/src/serveme-tf/list-regions.ts @@ -0,0 +1,10 @@ +import { client } from './client' + +export async function listRegions() { + if (!client) { + throw new Error(`serveme.tf disabled`) + } + + const { servers } = await client.findOptions() + return [...new Set(servers.map(({ flag }) => flag))] +} diff --git a/src/serveme-tf/pick-server.test.ts b/src/serveme-tf/pick-server.test.ts new file mode 100644 index 00000000..83dbd828 --- /dev/null +++ b/src/serveme-tf/pick-server.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { pickServer } from './pick-server' +import type { ServerId } from '@tf2pickup-org/serveme-tf-client' + +const configuration = vi.hoisted(() => new Map([])) +vi.mock('../configuration', () => ({ + configuration: { + get: vi.fn().mockImplementation((key: string) => Promise.resolve(configuration.get(key))), + }, +})) + +describe('pickServer()', () => { + beforeEach(() => { + configuration.set('serveme_tf.preferred_region', null) + configuration.set('serveme_tf.ban_gameservers', []) + }) + + it('should pick a gameserver randomly', async () => { + expect(await pickServer([{ id: 42 as ServerId, flag: 'pl', name: 'FAKE_GAMESERVER_42' }])).toBe( + 42, + ) + }) + + describe('when no gameservers are provided', () => { + it('should throw', async () => { + await expect(pickServer([])).rejects.toThrow() + }) + }) + + describe('when preferred region is set', () => { + beforeEach(() => { + configuration.set('serveme_tf.preferred_region', 'pl') + }) + + it('should pick a gameserver from the preferred region', async () => { + expect( + await pickServer([ + { id: 41 as ServerId, flag: 'de', name: 'FAKE_GAMESERVER_41' }, + { + id: 42 as ServerId, + flag: 'pl', + name: 'FAKE_GAMESERVER_42', + }, + ]), + ).toEqual(42) + }) + + it('should pick another gameserver if preferred one is unavailable', async () => { + expect( + await pickServer([{ id: 41 as ServerId, flag: 'de', name: 'FAKE_GAMESERVER_41' }]), + ).toEqual(41) + }) + }) + + describe('when some gameservers are banned', () => { + beforeEach(() => { + configuration.set('serveme_tf.ban_gameservers', ['bad gameserver']) + }) + + it('should not return banned gameservers', async () => { + expect( + await pickServer([ + { id: 41 as ServerId, flag: 'de', name: 'bad gameserver' }, + { + id: 42 as ServerId, + flag: 'pl', + name: 'good gameserver', + }, + ]), + ).toBe(42) + }) + + describe('and no gameservers are matched', () => { + it('should throw', async () => { + await expect( + pickServer([{ id: 41 as ServerId, flag: 'de', name: 'bad gameserver' }]), + ).rejects.toThrow() + }) + }) + }) +}) diff --git a/src/serveme-tf/pick-server.ts b/src/serveme-tf/pick-server.ts new file mode 100644 index 00000000..34f6635c --- /dev/null +++ b/src/serveme-tf/pick-server.ts @@ -0,0 +1,38 @@ +import type { ServerId } from '@tf2pickup-org/serveme-tf-client' +import { configuration } from '../configuration' +import { sample } from 'lodash-es' + +interface ServerData { + id: ServerId + flag: string + name: string +} + +export async function pickServer(servers: ServerData[]): Promise { + const bannedServers = await configuration.get('serveme_tf.ban_gameservers') + + const validServers = (await byFilterRegion(servers)).filter(s => + bannedServers.every(ban => !s.name.includes(ban)), + ) + + const server = sample(validServers) + if (!server) { + throw new Error('could not find any gameservers meeting given criteria') + } + + return server.id +} + +async function byFilterRegion(servers: ServerData[]): Promise { + const preferredRegion = await configuration.get('serveme_tf.preferred_region') + if (preferredRegion === null) { + return servers + } + + const matching = servers.filter(s => s.flag === preferredRegion) + if (matching.length === 0) { + return servers + } else { + return matching + } +} diff --git a/src/serveme-tf/plugins/end-reservation.ts b/src/serveme-tf/plugins/end-reservation.ts new file mode 100644 index 00000000..86d7d7ec --- /dev/null +++ b/src/serveme-tf/plugins/end-reservation.ts @@ -0,0 +1,39 @@ +import fp from 'fastify-plugin' +import { tasks } from '../../tasks' +import { client } from '../client' +import type { ReservationId } from '@tf2pickup-org/serveme-tf-client' +import { events } from '../../events' +import { whenGameEnds } from '../../games/when-game-ends' +import { GameServerProvider } from '../../database/models/game.model' +import { secondsToMilliseconds } from 'date-fns' +import { logger } from '../../logger' + +const endReservationDelay = secondsToMilliseconds(30) + +export default fp(async () => { + async function endReservation(id: ReservationId) { + if (!client) { + throw new Error(`serveme.tf disabled`) + } + + const reservation = await client.fetch(id) + await reservation.end() + logger.debug({ reservationId: reservation.id }, `reservation ended`) + } + + tasks.register('servemeTf:endReservation', async ({ id }) => { + await endReservation(id as ReservationId) + }) + + events.on( + 'game:updated', + whenGameEnds(async ({ after }) => { + if (after.gameServer?.provider !== GameServerProvider.servemeTf) { + return + } + + const id = Number(after.gameServer.id) + tasks.schedule('servemeTf:endReservation', endReservationDelay, { id }) + }), + ) +}) diff --git a/src/serveme-tf/wait-for-start.ts b/src/serveme-tf/wait-for-start.ts new file mode 100644 index 00000000..01482311 --- /dev/null +++ b/src/serveme-tf/wait-for-start.ts @@ -0,0 +1,9 @@ +import type { ReservationId } from '@tf2pickup-org/serveme-tf-client' +import { get } from './cache' +import { logger } from '../logger' + +export async function waitForStart(reservationId: ReservationId) { + const r = await get(reservationId) + await r.waitForStarted() + logger.debug({ reservationId: r.id }, `gameserver started`) +} diff --git a/src/static-game-servers/assign.ts b/src/static-game-servers/assign.ts index 6ae8e2d2..09716f96 100644 --- a/src/static-game-servers/assign.ts +++ b/src/static-game-servers/assign.ts @@ -1,18 +1,18 @@ import { Mutex } from 'async-mutex' -import type { GameModel } from '../database/models/game.model' +import { GameServerProvider, type GameModel, type GameServer } from '../database/models/game.model' import { findFree } from './find-free' import { update } from './update' const mutex = new Mutex() -export async function assign(game: GameModel) { +export async function assign(game: GameModel): Promise { return await mutex.runExclusive(async () => { const before = await findFree() if (!before) { throw new Error(`no free servers available for game ${game.number}`) } - return await update( + const server = await update( { id: before.id, }, @@ -22,5 +22,19 @@ export async function assign(game: GameModel) { }, }, ) + + return { + provider: GameServerProvider.static, + id: server.id, + name: server.name, + address: server.address, + port: server.port, + + rcon: { + address: server.internalIpAddress, + port: server.port, + password: server.rconPassword, + }, + } }) } diff --git a/src/tasks/tasks.ts b/src/tasks/tasks.ts index ef4af4cc..d443469f 100644 --- a/src/tasks/tasks.ts +++ b/src/tasks/tasks.ts @@ -34,6 +34,10 @@ export const tasksSchema = z.discriminatedUnion('name', [ name: z.literal('staticGameServers:free'), args: z.object({ id: z.string() }), }), + z.object({ + name: z.literal('servemeTf:endReservation'), + args: z.object({ id: z.number() }), + }), ]) type TasksT = z.infer diff --git a/tests/20-game/02-free-players.spec.ts b/tests/20-game/02-free-players.spec.ts index 79f40270..579b0f3e 100644 --- a/tests/20-game/02-free-players.spec.ts +++ b/tests/20-game/02-free-players.spec.ts @@ -29,13 +29,15 @@ launchGame('free players when the game ends', async ({ players, gameServer, game .map(playerName => players.find(p => p.playerName === playerName)!) .map(async player => { const page = await player.queuePage() - await page.goto() await Promise.all([ expect(page.goBackToGameLink()).not.toBeVisible({ timeout: secondsToMilliseconds(1), }), ...Array.from(Array(12).keys()).map( - async i => await expect(page.slot(i).joinButton()).toBeEnabled(), + async i => + await expect(page.slot(i).joinButton()).toBeEnabled({ + timeout: secondsToMilliseconds(1), + }), ), ]) }), @@ -43,11 +45,13 @@ launchGame('free players when the game ends', async ({ players, gameServer, game .filter(p => !medics.includes(p.playerName)) .map(async player => { const page = await player.queuePage() - await page.goto() await Promise.all([ - expect(page.goBackToGameLink()).toBeVisible(), + expect(page.goBackToGameLink()).toBeVisible({ timeout: secondsToMilliseconds(1) }), ...Array.from(Array(12).keys()).map( - async i => await expect(page.slot(i).joinButton()).toBeDisabled(), + async i => + await expect(page.slot(i).joinButton()).toBeDisabled({ + timeout: secondsToMilliseconds(1), + }), ), ]) }), @@ -59,13 +63,15 @@ launchGame('free players when the game ends', async ({ players, gameServer, game .filter(p => !medics.includes(p.playerName)) .map(async player => { const page = await player.queuePage() - await page.goto() await Promise.all([ expect(page.goBackToGameLink()).not.toBeVisible({ - timeout: secondsToMilliseconds(5), + timeout: secondsToMilliseconds(6), }), ...Array.from(Array(12).keys()).map( - async i => await expect(page.slot(i).joinButton()).toBeEnabled(), + async i => + await expect(page.slot(i).joinButton()).toBeEnabled({ + timeout: secondsToMilliseconds(6), + }), ), ]) }),