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 (
+
+
+
+ {props?.saveResult?.success === true && (
+
+ saved!
+
+ )}
+
+ )
+}
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),
+ }),
),
])
}),