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 2eb8ec77..9fb88049 100644 --- a/src/admin/game-servers/views/html/game-servers.page.tsx +++ b/src/admin/game-servers/views/html/game-servers.page.tsx @@ -1,10 +1,14 @@ import type { User } from '../../../../auth/types/user' import { Admin } from '../../../views/html/admin' +import { StaticGameServerList } from './static-game-server-list' -export function GameServersPage(props: { user: User }) { +export async function GameServersPage(props: { user: User }) { return ( -
+
+

Static servers

+ +
) } diff --git a/src/admin/game-servers/views/html/static-game-server-list.tsx b/src/admin/game-servers/views/html/static-game-server-list.tsx new file mode 100644 index 00000000..86138e04 --- /dev/null +++ b/src/admin/game-servers/views/html/static-game-server-list.tsx @@ -0,0 +1,72 @@ +import { collections } from '../../../../database/collections' +import type { StaticGameServerModel } from '../../../../database/models/static-game-server.model' +import { IconCheck, IconMinus, IconSquareXFilled, IconX } from '../../../../html/components/icons' + +export async function StaticGameServerList() { + const staticGameServers = await collections.staticGameServers + .find() + .sort({ isOnline: -1, lastHeartbeatAt: -1, priority: -1 }) + .toArray() + + return ( + + + + + + + + + + + + + + {staticGameServers.map(gameServer => ( + + ))} + +
NameIP addressInternal IP addressRCON passwordOnlineAssigned to game
+ ) +} + +function StaticGameServerItem(props: { gameServer: StaticGameServerModel }) { + return ( + + + {props.gameServer.name} + + + {props.gameServer.address}:{props.gameServer.port} + + + {props.gameServer.internalIpAddress}:{props.gameServer.port} + + + {props.gameServer.rconPassword} + + + {props.gameServer.isOnline ? ( + + ) : ( + + )} + + + {props.gameServer.game ? ( +
+ + #{props.gameServer.game} + + +
+ ) : ( + + )} + + + ) +} diff --git a/src/game-servers/assign.ts b/src/game-servers/assign.ts index 3e5fd187..67cd5397 100644 --- a/src/game-servers/assign.ts +++ b/src/game-servers/assign.ts @@ -10,18 +10,18 @@ const mutex = new Mutex() export async function assign(game: GameModel) { await mutex.runExclusive(async () => { - const freeServer = await staticGameServers.findFree() - if (!freeServer) { + const staticGameServer = await staticGameServers.assign(game) + if (!staticGameServer) { throw new Error(`no free servers available for game ${game.number}`) } game = await games.update(game.number, { $set: { gameServer: { - id: freeServer.id, - name: freeServer.name, - address: freeServer.address, - port: freeServer.port, + id: staticGameServer.id, + name: staticGameServer.name, + address: staticGameServer.address, + port: staticGameServer.port, provider: GameServerProvider.static, }, }, @@ -29,11 +29,11 @@ export async function assign(game: GameModel) { events: { event: GameEventType.gameServerAssigned, at: new Date(), - gameServerName: freeServer.name, + gameServerName: staticGameServer.name, }, }, }) - logger.info({ game }, `game ${game.number} assigned to game server ${freeServer.name}`) + logger.info({ game }, `game ${game.number} assigned to game server ${staticGameServer.name}`) events.emit('game:gameServerAssigned', { game }) }) } diff --git a/src/game-servers/plugins/auto-assign-game-server.ts b/src/game-servers/plugins/auto-assign-game-server.ts index 6c984237..d30adcc0 100644 --- a/src/game-servers/plugins/auto-assign-game-server.ts +++ b/src/game-servers/plugins/auto-assign-game-server.ts @@ -3,16 +3,16 @@ import { events } from '../../events' import { assign } from '../assign' import { getOrphanedGames } from '../get-orphaned-games' import { logger } from '../../logger' +import { safe } from '../../utils/safe' export default fp( async () => { - events.on('game:created', async ({ game }) => { - try { + events.on( + 'game:created', + safe(async ({ game }) => { await assign(game) - } catch (error) { - logger.error(error) - } - }) + }), + ) const orphanedGames = await getOrphanedGames() for (const game of orphanedGames) { diff --git a/src/html/components/icons/icon-check.tsx b/src/html/components/icons/icon-check.tsx new file mode 100644 index 00000000..f1e83e99 --- /dev/null +++ b/src/html/components/icons/icon-check.tsx @@ -0,0 +1,9 @@ +import { makeIcon } from './make-icon' + +export const IconCheck = makeIcon( + 'check', + <> + + + , +) diff --git a/src/html/components/icons/icon-square-x-filled.tsx b/src/html/components/icons/icon-square-x-filled.tsx new file mode 100644 index 00000000..630f1548 --- /dev/null +++ b/src/html/components/icons/icon-square-x-filled.tsx @@ -0,0 +1,9 @@ +import { makeIconFilled } from './make-icon' + +export const IconSquareXFilled = makeIconFilled( + 'square-x-filled', + <> + + + , +) diff --git a/src/html/components/icons/icon-square-x.tsx b/src/html/components/icons/icon-square-x.tsx new file mode 100644 index 00000000..795bf8ee --- /dev/null +++ b/src/html/components/icons/icon-square-x.tsx @@ -0,0 +1,10 @@ +import { makeIcon } from './make-icon' + +export const IconSquareX = makeIcon( + 'square-x', + <> + + + + , +) diff --git a/src/html/components/icons/index.ts b/src/html/components/icons/index.ts index ad8c50d2..f2b3319f 100644 --- a/src/html/components/icons/index.ts +++ b/src/html/components/icons/index.ts @@ -8,6 +8,7 @@ export { IconBrandDiscord } from './icon-brand-discord' export { IconChartArrowsVertical } from './icon-chart-arrows-vertical' export { IconBrandSteam } from './icon-brand-steam' export { IconChartPie } from './icon-chart-pie' +export { IconCheck } from './icon-check' export { IconChevronLeft } from './icon-chevron-left' export { IconChevronRight } from './icon-chevron-right' export { IconCoffee } from './icon-coffee' @@ -39,6 +40,8 @@ export { IconServer } from './icon-server' export { IconSettingsFilled } from './icon-settings-filled' export { IconSettings } from './icon-settings' export { IconSpy } from './icon-spy' +export { IconSquareXFilled } from './icon-square-x-filled' +export { IconSquareX } from './icon-square-x' export { IconStars } from './icon-stars' export { IconSum } from './icon-sum' export { IconTable } from './icon-table' diff --git a/src/static-game-servers/assign.ts b/src/static-game-servers/assign.ts new file mode 100644 index 00000000..6ae8e2d2 --- /dev/null +++ b/src/static-game-servers/assign.ts @@ -0,0 +1,26 @@ +import { Mutex } from 'async-mutex' +import type { GameModel } from '../database/models/game.model' +import { findFree } from './find-free' +import { update } from './update' + +const mutex = new Mutex() + +export async function assign(game: GameModel) { + 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( + { + id: before.id, + }, + { + $set: { + game: game.number, + }, + }, + ) + }) +} diff --git a/src/static-game-servers/index.ts b/src/static-game-servers/index.ts index 9910b09b..8d4435ce 100644 --- a/src/static-game-servers/index.ts +++ b/src/static-game-servers/index.ts @@ -4,8 +4,12 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod' import { z } from 'zod' import { logger } from '../logger' import { heartbeat } from './heartbeat' +import { resolve } from 'node:path' +import { assign } from './assign' +import { update } from './update' export const staticGameServers = { + assign, findFree, } as const @@ -16,36 +20,54 @@ export default fp( address: z.string(), port: z.string(), rconPassword: z.string(), - priority: z.coerce.number().optional(), + priority: z.coerce.number().default(0), internalIpAddress: z.string().optional(), }) - app.withTypeProvider().post( - '/static-game-servers/', - { - schema: { - body: gameServerHeartbeatSchema, + app + .withTypeProvider() + .post( + '/static-game-servers/', + { + schema: { + body: gameServerHeartbeatSchema, + }, }, - }, - async (req, reply) => { - const { name, address, port, rconPassword, priority, internalIpAddress } = req.body - logger.info( - { name, address, port, rconPassword, priority, internalIpAddress }, - 'game server heartbeat', - ) - await heartbeat({ - name, - address, - port, - rconPassword, - priority: priority ?? 0, - internalIpAddress: internalIpAddress ?? req.ip, - }) - await reply.status(200).send() - }, - ) + async (req, reply) => { + const { name, address, port, rconPassword, priority, internalIpAddress } = req.body + logger.info( + { name, address, port, rconPassword, priority, internalIpAddress }, + 'game server heartbeat', + ) + await heartbeat({ + name, + address, + port, + rconPassword, + priority: priority, + internalIpAddress: internalIpAddress ?? req.ip, + }) + await reply.status(200).send() + }, + ) + .delete( + '/static-game-servers/:id/game', + { + schema: { + params: z.object({ + id: z.string(), + }), + }, + }, + async (request, reply) => { + await update({ id: request.params.id }, { $unset: { game: 1 } }) + await reply.status(204).send() + }, + ) - await app.register((await import('./plugins/remove-dead-game-servers')).default) + await app.register((await import('@fastify/autoload')).default, { + dir: resolve(import.meta.dirname, 'plugins'), + }) }, { name: 'static game servers' }, ) diff --git a/src/static-game-servers/plugins/free-game-servers.ts b/src/static-game-servers/plugins/free-game-servers.ts new file mode 100644 index 00000000..a7b0834e --- /dev/null +++ b/src/static-game-servers/plugins/free-game-servers.ts @@ -0,0 +1,35 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { update } from '../update' +import { tasks } from '../../tasks' +import { secondsToMilliseconds } from 'date-fns' +import { whenGameEnds } from '../../games/when-game-ends' +import { GameServerProvider, GameState } from '../../database/models/game.model' + +const freeGameServerDelay = secondsToMilliseconds(30) + +export default fp( + async () => { + tasks.register('staticGameServers:free', async ({ id }) => { + await update({ id }, { $unset: { game: 1 } }) + }) + + events.on( + 'game:updated', + whenGameEnds(async ({ after }) => { + if (after.gameServer?.provider !== GameServerProvider.static) { + return + } + + if (after.state === GameState.interrupted) { + await update({ id: after.gameServer.id }, { $unset: { game: 1 } }) + } else { + tasks.schedule('staticGameServers:free', freeGameServerDelay, { id: after.gameServer.id }) + } + }), + ) + }, + { + name: 'free static game servers', + }, +) diff --git a/src/static-game-servers/plugins/remove-dead-game-servers.ts b/src/static-game-servers/plugins/remove-dead-game-servers.ts index 901ddf39..f65001c5 100644 --- a/src/static-game-servers/plugins/remove-dead-game-servers.ts +++ b/src/static-game-servers/plugins/remove-dead-game-servers.ts @@ -2,25 +2,21 @@ import { subMinutes } from 'date-fns' import fp from 'fastify-plugin' import { collections } from '../../database/collections' import { Cron } from 'croner' - -async function removeDeadGameServers() { - const fiveMinutesAgo = subMinutes(new Date(), 5) - await collections.staticGameServers.updateMany( - { - isOnline: true, - lastHeartbeatAt: { $lt: fiveMinutesAgo }, - }, - { - $set: { - isOnline: false, - }, - }, - ) -} +import { update } from '../update' export default fp( // eslint-disable-next-line @typescript-eslint/require-await async () => { + async function removeDeadGameServers() { + const fiveMinutesAgo = subMinutes(new Date(), 5) + const dead = await collections.staticGameServers + .find({ isOnline: true, lastHeartbeatAt: { $lt: fiveMinutesAgo } }) + .toArray() + for (const server of dead) { + await update({ id: server.id }, { $set: { isOnline: false } }) + } + } + // run every minute new Cron('* * * * *', removeDeadGameServers) }, diff --git a/src/static-game-servers/plugins/sync-clients.ts b/src/static-game-servers/plugins/sync-clients.ts new file mode 100644 index 00000000..a9c518c4 --- /dev/null +++ b/src/static-game-servers/plugins/sync-clients.ts @@ -0,0 +1,15 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { StaticGameServerList } from '../../admin/game-servers/views/html/static-game-server-list' + +export default fp(async app => { + events.on('staticGameServer:updated', () => { + const list = StaticGameServerList() + app.gateway.broadcast(() => list) + }) + + events.on('staticGameServer:added', () => { + const list = StaticGameServerList() + app.gateway.broadcast(() => list) + }) +}) diff --git a/src/static-game-servers/update.ts b/src/static-game-servers/update.ts new file mode 100644 index 00000000..0571033a --- /dev/null +++ b/src/static-game-servers/update.ts @@ -0,0 +1,29 @@ +import type { StrictFilter, StrictUpdateFilter } from 'mongodb' +import type { StaticGameServerModel } from '../database/models/static-game-server.model' +import { Mutex } from 'async-mutex' +import { collections } from '../database/collections' +import { events } from '../events' + +const mutex = new Mutex() + +export async function update( + filter: StrictFilter, + update: StrictUpdateFilter, +): Promise { + return await mutex.runExclusive(async () => { + const before = await collections.staticGameServers.findOne(filter) + if (!before) { + throw new Error(`static game server (${JSON.stringify(filter)}) not found`) + } + + const after = await collections.staticGameServers.findOneAndUpdate(filter, update, { + returnDocument: 'after', + }) + if (!after) { + throw new Error(`can't update static game server ${JSON.stringify(filter)}`) + } + + events.emit('staticGameServer:updated', { before, after }) + return after + }) +} diff --git a/src/tasks/tasks.ts b/src/tasks/tasks.ts index 1a65fcea..ef4af4cc 100644 --- a/src/tasks/tasks.ts +++ b/src/tasks/tasks.ts @@ -30,6 +30,10 @@ export const tasksSchema = z.discriminatedUnion('name', [ name: z.literal('queue:unready'), args: z.object({}), }), + z.object({ + name: z.literal('staticGameServers:free'), + args: z.object({ id: z.string() }), + }), ]) type TasksT = z.infer diff --git a/tests/fixtures/launch-game.ts b/tests/fixtures/launch-game.ts index 5f99532f..a55c1312 100644 --- a/tests/fixtures/launch-game.ts +++ b/tests/fixtures/launch-game.ts @@ -97,15 +97,18 @@ export const launchGame = mergeTests(authUsers, simulateGameServer, waitForEmpty } // kill the game if it's live - const adminPage = await users.getAdmin().gamePage(gameNumber) - await adminPage.goto() - if (await adminPage.isLive()) { + const gamePage = await users.getAdmin().gamePage(gameNumber) + await gamePage.goto() + if (await gamePage.isLive()) { if (waitForStage === 'started') { await gameServer.matchEnds() } else { - await adminPage.forceEnd() + await gamePage.forceEnd() } } + + const adminPage = await users.getAdmin().adminPage() + await adminPage.freeStaticGameServer() }, }) diff --git a/tests/pages/admin.page.ts b/tests/pages/admin.page.ts index 99ce6a98..f8583161 100644 --- a/tests/pages/admin.page.ts +++ b/tests/pages/admin.page.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test' +import { secondsToMilliseconds } from 'date-fns' export class AdminPage { constructor(public readonly page: Page) {} @@ -22,4 +23,13 @@ export class AdminPage { await revokeButton.click() } } + + async freeStaticGameServer() { + await this.page.goto('/admin/game-servers') + try { + await this.page + .getByRole('button', { name: 'Remove game assignment' }) + .click({ timeout: secondsToMilliseconds(1) }) + } catch (error) {} + } }