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 (
+
+
+
+ Name |
+ IP address |
+ Internal IP address |
+ RCON password |
+ Online |
+ Assigned to game |
+
+
+
+
+ {staticGameServers.map(gameServer => (
+
+ ))}
+
+
+ )
+}
+
+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 ? (
+
+ ) : (
+
+ )}
+ |
+
+ )
+}
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) {}
+ }
}