Skip to content

Commit 1f41027

Browse files
committed
feat: rotate static game servers
1 parent 104751e commit 1f41027

File tree

13 files changed

+220
-34
lines changed

13 files changed

+220
-34
lines changed
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { User } from '../../../../auth/types/user'
22
import { Admin } from '../../../views/html/admin'
3+
import { StaticGameServerList } from './static-game-server-list'
34

4-
export function GameServersPage(props: { user: User }) {
5+
export async function GameServersPage(props: { user: User }) {
56
return (
67
<Admin activePage="game-servers" user={props.user}>
7-
<div class="admin-panel-set"></div>
8+
<div class="admin-panel-set">
9+
<h4 class="pb-4">Static servers</h4>
10+
<StaticGameServerList />
11+
</div>
812
</Admin>
913
)
1014
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { collections } from '../../../../database/collections'
2+
import type { StaticGameServerModel } from '../../../../database/models/static-game-server.model'
3+
import { IconCheck, IconX } from '../../../../html/components/icons'
4+
5+
export async function StaticGameServerList() {
6+
const staticGameServers = await collections.staticGameServers
7+
.find()
8+
.sort({ isOnline: -1, lastHeartbeatAt: -1, priority: -1 })
9+
.toArray()
10+
11+
return (
12+
<table class="w-full table-auto" id="admin-panel-static-game-server-list">
13+
<thead>
14+
<tr>
15+
<th class="border-b border-ash/50 pb-3 text-left">Name</th>
16+
<th class="border-b border-ash/50 pb-3 text-left">IP address</th>
17+
<th class="border-b border-ash/50 pb-3 text-left">Internal IP address</th>
18+
<th class="border-b border-ash/50 pb-3 text-left">RCON password</th>
19+
<th class="border-b border-ash/50 pb-3 text-left">Online</th>
20+
<th class="border-b border-ash/50 pb-3 text-left"></th>
21+
</tr>
22+
</thead>
23+
24+
<tbody>
25+
{staticGameServers.map(gameServer => (
26+
<StaticGameServerItem gameServer={gameServer} />
27+
))}
28+
</tbody>
29+
</table>
30+
)
31+
}
32+
33+
function StaticGameServerItem(props: { gameServer: StaticGameServerModel }) {
34+
return (
35+
<tr>
36+
<td class="border-b border-ash/20 py-4 font-bold" safe>
37+
{props.gameServer.name}
38+
</td>
39+
<td class="border-b border-ash/20 py-4" safe>
40+
{props.gameServer.address}:{props.gameServer.port}
41+
</td>
42+
<td class="border-b border-ash/20 py-4" safe>
43+
{props.gameServer.internalIpAddress}:{props.gameServer.port}
44+
</td>
45+
<td class="border-b border-ash/20 py-4" safe>
46+
{props.gameServer.rconPassword}
47+
</td>
48+
<td class="border-b border-ash/20 py-4">
49+
{props.gameServer.isOnline ? (
50+
<IconCheck class="text-green-600" />
51+
) : (
52+
<IconX class="text-red-600" />
53+
)}
54+
</td>
55+
<td class="min-w-[60px] border-b border-ash/20 py-4">
56+
{props.gameServer.game ? (
57+
<a href={`/games/${props.gameServer.game}`} safe>
58+
#{props.gameServer.game}
59+
</a>
60+
) : (
61+
<></>
62+
)}
63+
</td>
64+
</tr>
65+
)
66+
}

src/game-servers/assign.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,30 @@ const mutex = new Mutex()
1010

1111
export async function assign(game: GameModel) {
1212
await mutex.runExclusive(async () => {
13-
const freeServer = await staticGameServers.findFree()
14-
if (!freeServer) {
13+
const staticGameServer = await staticGameServers.assign(game)
14+
if (!staticGameServer) {
1515
throw new Error(`no free servers available for game ${game.number}`)
1616
}
1717

1818
game = await games.update(game.number, {
1919
$set: {
2020
gameServer: {
21-
id: freeServer.id,
22-
name: freeServer.name,
23-
address: freeServer.address,
24-
port: freeServer.port,
21+
id: staticGameServer.id,
22+
name: staticGameServer.name,
23+
address: staticGameServer.address,
24+
port: staticGameServer.port,
2525
provider: GameServerProvider.static,
2626
},
2727
},
2828
$push: {
2929
events: {
3030
event: GameEventType.gameServerAssigned,
3131
at: new Date(),
32-
gameServerName: freeServer.name,
32+
gameServerName: staticGameServer.name,
3333
},
3434
},
3535
})
36-
logger.info({ game }, `game ${game.number} assigned to game server ${freeServer.name}`)
36+
logger.info({ game }, `game ${game.number} assigned to game server ${staticGameServer.name}`)
3737
events.emit('game:gameServerAssigned', { game })
3838
})
3939
}

src/game-servers/plugins/auto-assign-game-server.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@ import { events } from '../../events'
33
import { assign } from '../assign'
44
import { getOrphanedGames } from '../get-orphaned-games'
55
import { logger } from '../../logger'
6+
import { safe } from '../../utils/safe'
67

78
export default fp(
89
async () => {
9-
events.on('game:created', async ({ game }) => {
10-
try {
10+
events.on(
11+
'game:created',
12+
safe(async ({ game }) => {
1113
await assign(game)
12-
} catch (error) {
13-
logger.error(error)
14-
}
15-
})
14+
}),
15+
)
1616

1717
const orphanedGames = await getOrphanedGames()
1818
for (const game of orphanedGames) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { makeIcon } from './make-icon'
2+
3+
export const IconCheck = makeIcon(
4+
'check',
5+
<>
6+
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
7+
<path d="M5 12l5 5l10 -10" />
8+
</>,
9+
)

src/html/components/icons/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { IconBrandDiscord } from './icon-brand-discord'
88
export { IconChartArrowsVertical } from './icon-chart-arrows-vertical'
99
export { IconBrandSteam } from './icon-brand-steam'
1010
export { IconChartPie } from './icon-chart-pie'
11+
export { IconCheck } from './icon-check'
1112
export { IconChevronLeft } from './icon-chevron-left'
1213
export { IconChevronRight } from './icon-chevron-right'
1314
export { IconCoffee } from './icon-coffee'

src/static-game-servers/assign.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Mutex } from 'async-mutex'
2+
import type { GameModel } from '../database/models/game.model'
3+
import { findFree } from './find-free'
4+
import { update } from './update'
5+
6+
const mutex = new Mutex()
7+
8+
export async function assign(game: GameModel) {
9+
return await mutex.runExclusive(async () => {
10+
const before = await findFree()
11+
if (!before) {
12+
throw new Error(`no free servers available for game ${game.number}`)
13+
}
14+
15+
return await update(
16+
{
17+
id: before.id,
18+
},
19+
{
20+
$set: {
21+
game: game.number,
22+
},
23+
},
24+
)
25+
})
26+
}

src/static-game-servers/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
44
import { z } from 'zod'
55
import { logger } from '../logger'
66
import { heartbeat } from './heartbeat'
7+
import { resolve } from 'node:path'
8+
import { assign } from './assign'
79

810
export const staticGameServers = {
11+
assign,
912
findFree,
1013
} as const
1114

@@ -16,7 +19,7 @@ export default fp(
1619
address: z.string(),
1720
port: z.string(),
1821
rconPassword: z.string(),
19-
priority: z.coerce.number().optional(),
22+
priority: z.coerce.number().default(0),
2023
internalIpAddress: z.string().optional(),
2124
})
2225

@@ -38,14 +41,16 @@ export default fp(
3841
address,
3942
port,
4043
rconPassword,
41-
priority: priority ?? 0,
44+
priority: priority,
4245
internalIpAddress: internalIpAddress ?? req.ip,
4346
})
4447
await reply.status(200).send()
4548
},
4649
)
4750

48-
await app.register((await import('./plugins/remove-dead-game-servers')).default)
51+
await app.register((await import('@fastify/autoload')).default, {
52+
dir: resolve(import.meta.dirname, 'plugins'),
53+
})
4954
},
5055
{ name: 'static game servers' },
5156
)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import fp from 'fastify-plugin'
2+
import { events } from '../../events'
3+
import { update } from '../update'
4+
import { tasks } from '../../tasks'
5+
import { secondsToMilliseconds } from 'date-fns'
6+
import { whenGameEnds } from '../../games/when-game-ends'
7+
import { GameServerProvider } from '../../database/models/game.model'
8+
9+
const freeGameServerDelay = secondsToMilliseconds(30)
10+
11+
export default fp(
12+
async () => {
13+
tasks.register('staticGameServers:free', async ({ id }) => {
14+
await update({ id }, { $unset: { game: 1 } })
15+
})
16+
17+
events.on(
18+
'game:updated',
19+
whenGameEnds(({ after }) => {
20+
if (after.gameServer?.provider !== GameServerProvider.static) {
21+
return
22+
}
23+
24+
tasks.schedule('staticGameServers:free', freeGameServerDelay, { id: after.gameServer.id })
25+
}),
26+
)
27+
},
28+
{
29+
name: 'free static game servers',
30+
},
31+
)

src/static-game-servers/plugins/remove-dead-game-servers.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,21 @@ import { subMinutes } from 'date-fns'
22
import fp from 'fastify-plugin'
33
import { collections } from '../../database/collections'
44
import { Cron } from 'croner'
5-
6-
async function removeDeadGameServers() {
7-
const fiveMinutesAgo = subMinutes(new Date(), 5)
8-
await collections.staticGameServers.updateMany(
9-
{
10-
isOnline: true,
11-
lastHeartbeatAt: { $lt: fiveMinutesAgo },
12-
},
13-
{
14-
$set: {
15-
isOnline: false,
16-
},
17-
},
18-
)
19-
}
5+
import { update } from '../update'
206

217
export default fp(
228
// eslint-disable-next-line @typescript-eslint/require-await
239
async () => {
10+
async function removeDeadGameServers() {
11+
const fiveMinutesAgo = subMinutes(new Date(), 5)
12+
const dead = await collections.staticGameServers
13+
.find({ isOnline: true, lastHeartbeatAt: { $lt: fiveMinutesAgo } })
14+
.toArray()
15+
for (const server of dead) {
16+
await update({ id: server.id }, { $set: { isOnline: false } })
17+
}
18+
}
19+
2420
// run every minute
2521
new Cron('* * * * *', removeDeadGameServers)
2622
},
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import fp from 'fastify-plugin'
2+
import { events } from '../../events'
3+
import { StaticGameServerList } from '../../admin/game-servers/views/html/static-game-server-list'
4+
5+
export default fp(async app => {
6+
events.on('staticGameServer:updated', () => {
7+
const list = StaticGameServerList()
8+
app.gateway.broadcast(() => list)
9+
})
10+
11+
events.on('staticGameServer:added', () => {
12+
const list = StaticGameServerList()
13+
app.gateway.broadcast(() => list)
14+
})
15+
})

src/static-game-servers/update.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { StrictFilter, StrictUpdateFilter } from 'mongodb'
2+
import type { StaticGameServerModel } from '../database/models/static-game-server.model'
3+
import { Mutex } from 'async-mutex'
4+
import { collections } from '../database/collections'
5+
import { events } from '../events'
6+
7+
const mutex = new Mutex()
8+
9+
export async function update(
10+
filter: StrictFilter<StaticGameServerModel>,
11+
update: StrictUpdateFilter<StaticGameServerModel>,
12+
): Promise<StaticGameServerModel> {
13+
return await mutex.runExclusive(async () => {
14+
const before = await collections.staticGameServers.findOne(filter)
15+
if (!before) {
16+
throw new Error(`static game server (${JSON.stringify(filter)}) not found`)
17+
}
18+
19+
const after = await collections.staticGameServers.findOneAndUpdate(filter, update, {
20+
returnDocument: 'after',
21+
})
22+
if (!after) {
23+
throw new Error(`can't update static game server ${JSON.stringify(filter)}`)
24+
}
25+
26+
events.emit('staticGameServer:updated', { before, after })
27+
return after
28+
})
29+
}

src/tasks/tasks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export const tasksSchema = z.discriminatedUnion('name', [
3030
name: z.literal('queue:unready'),
3131
args: z.object({}),
3232
}),
33+
z.object({
34+
name: z.literal('staticGameServers:free'),
35+
args: z.object({ id: z.string() }),
36+
}),
3337
])
3438

3539
type TasksT = z.infer<typeof tasksSchema>

0 commit comments

Comments
 (0)