From 55d8a419ece5a7104a91cdd512e3172ea6d14da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Fri, 13 Dec 2024 18:31:28 +0100 Subject: [PATCH] feat: auto-close games with too many substitute requests --- .../models/configuration-entry.model.ts | 9 ++++ src/database/models/game-event.model.ts | 1 + src/games/force-end.ts | 9 +++- src/games/plugins/auto-close-games.ts | 53 +++++++++++++++++++ src/games/views/html/game-event-list.tsx | 2 + tests/20-game/07-auto-close-games.spec.ts | 12 +++++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/games/plugins/auto-close-games.ts create mode 100644 tests/20-game/07-auto-close-games.spec.ts diff --git a/src/database/models/configuration-entry.model.ts b/src/database/models/configuration-entry.model.ts index b86691b5..4f960474 100644 --- a/src/database/models/configuration-entry.model.ts +++ b/src/database/models/configuration-entry.model.ts @@ -82,6 +82,15 @@ export const configurationSchema = z.discriminatedUnion('key', [ }) .describe('Apply cooldown before players can join the queue after a game ends'), }), + z.object({ + key: z.literal('games.auto_force_end_threshold'), + value: z + .number() + .default(4) + .describe( + 'Number of active substitute requests that make the game be automatically force-ended', + ), + }), z.object({ key: z.literal('players.etf2l_account_required'), value: z.boolean().default(false), diff --git a/src/database/models/game-event.model.ts b/src/database/models/game-event.model.ts index 012ac023..08e4471f 100644 --- a/src/database/models/game-event.model.ts +++ b/src/database/models/game-event.model.ts @@ -29,6 +29,7 @@ export interface GameCreated { export enum GameEndedReason { matchEnded = 'match ended', interrupted = 'interrupted', + tooManySubstituteRequests = 'too many substitute requests', } export interface GameEnded { diff --git a/src/games/force-end.ts b/src/games/force-end.ts index a092c304..85c3896a 100644 --- a/src/games/force-end.ts +++ b/src/games/force-end.ts @@ -1,10 +1,15 @@ import { GameEndedReason, GameEventType } from '../database/models/game-event.model' import { SlotStatus } from '../database/models/game-slot.model' import { GameState, type GameNumber } from '../database/models/game.model' +import type { Bot } from '../shared/types/bot' import type { SteamId64 } from '../shared/types/steam-id-64' import { update } from './update' -export async function forceEnd(gameNumber: GameNumber, actor?: SteamId64) { +export async function forceEnd( + gameNumber: GameNumber, + actor?: SteamId64 | Bot, + reason = GameEndedReason.interrupted, +) { await update( { number: gameNumber, @@ -18,7 +23,7 @@ export async function forceEnd(gameNumber: GameNumber, actor?: SteamId64) { events: { at: new Date(), event: GameEventType.gameEnded, - reason: GameEndedReason.interrupted, + reason, ...(actor && { actor }), }, }, diff --git a/src/games/plugins/auto-close-games.ts b/src/games/plugins/auto-close-games.ts new file mode 100644 index 00000000..b81c5e88 --- /dev/null +++ b/src/games/plugins/auto-close-games.ts @@ -0,0 +1,53 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { safe } from '../../utils/safe' +import { GameState, type GameModel } from '../../database/models/game.model' +import { configuration } from '../../configuration' +import { SlotStatus } from '../../database/models/game-slot.model' +import { debounce } from 'lodash-es' +import { logger } from '../../logger' +import { forceEnd } from '../force-end' +import { GameEndedReason } from '../../database/models/game-event.model' + +export default fp( + async () => { + const maybeCloseGame = debounce(async (game: GameModel) => { + if ( + ![ + GameState.created, + GameState.configuring, + GameState.launching, + GameState.started, + ].includes(game.state) + ) { + return + } + + const threshold = await configuration.get('games.auto_force_end_threshold') + if (threshold <= 0) { + return + } + + const subRequests = game.slots.filter( + slot => slot.status === SlotStatus.waitingForSubstitute, + ).length + if (subRequests >= threshold) { + logger.info( + { game, subRequestCount: subRequests, threshold }, + `game #${game.number} has too many substitute requests; the game will be force-ended`, + ) + await forceEnd(game.number, 'bot', GameEndedReason.tooManySubstituteRequests) + } + }, 100) + + events.on( + 'game:substituteRequested', + safe(async ({ game }) => { + await maybeCloseGame(game) + }), + ) + }, + { + name: 'auto close games with too many subsitute requests', + }, +) diff --git a/src/games/views/html/game-event-list.tsx b/src/games/views/html/game-event-list.tsx index d965942b..7e0a0153 100644 --- a/src/games/views/html/game-event-list.tsx +++ b/src/games/views/html/game-event-list.tsx @@ -137,6 +137,8 @@ async function GameEventInfo(props: { event: GameEventModel; game: GameModel }) ) + case GameEndedReason.tooManySubstituteRequests: + return Game interrupted (too many substitute requests) default: return Game ended } diff --git a/tests/20-game/07-auto-close-games.spec.ts b/tests/20-game/07-auto-close-games.spec.ts new file mode 100644 index 00000000..4785a744 --- /dev/null +++ b/tests/20-game/07-auto-close-games.spec.ts @@ -0,0 +1,12 @@ +import { expect, launchGame as test } from '../fixtures/launch-game' + +test('auto-closes games with too many substitute requests', async ({ gameNumber, users }) => { + const page = await users.getAdmin().gamePage(gameNumber) + await page.requestSubstitute('Mayflower') + await page.requestSubstitute('Polemic') + await page.requestSubstitute('Shadowhunter') + await page.requestSubstitute('MoonMan') + + await expect.poll(() => page.isLive()).toBe(false) + await expect(page.gameEvent('Game interrupted (too many substitute requests)')).toBeVisible() +})