diff --git a/packages/app-api/src/executors/executeFunction.ts b/packages/app-api/src/executors/executeFunction.ts index e9a8b797c5..0d74983326 100644 --- a/packages/app-api/src/executors/executeFunction.ts +++ b/packages/app-api/src/executors/executeFunction.ts @@ -7,7 +7,7 @@ import { IHookJobData, ICommandJobData, ICronJobData, isCommandData, isHookData, import { executeLambda } from '@takaro/aws'; import { config } from '../config.js'; import { RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'; -import { CommandService } from '../service/CommandService.js'; +import { commandsRunningKey, CommandService } from '../service/CommandService.js'; import { PlayerOnGameServerService } from '../service/PlayerOnGameserverService.js'; import { EVENT_TYPES, EventCreateDTO, EventService } from '../service/EventService.js'; import { @@ -23,6 +23,8 @@ import { } from '@takaro/modules'; import { HookService } from '../service/HookService.js'; import { CronJobService } from '../service/CronJobService.js'; +import { GameServerService } from '../service/GameServerService.js'; +import { IMessageOptsDTO, IPlayerReferenceDTO } from '@takaro/gameserver'; const rateLimiterMap: Map = new Map(); @@ -88,6 +90,7 @@ export async function executeFunction( ) { const rateLimiter = await getRateLimiter(domainId); const token = await getJobToken(domainId); + const redisClient = await Redis.getClient('worker:command-lock'); const client = new Client({ auth: { @@ -114,6 +117,7 @@ export async function executeFunction( }); if (isCommandData(data)) { + const gameserverService = new GameServerService(domainId); const commandService = new CommandService(domainId); const command = await commandService.findOne(data.itemId); if (!command) throw new errors.InternalServerError(); @@ -128,18 +132,84 @@ export async function executeFunction( if (!command) throw new errors.InternalServerError(); if ('commands' in data.module.systemConfig) { + // Handle cost const commandsConfig = data.module.systemConfig?.commands as Record; const cost = commandsConfig[command?.name]?.cost; if (cost) { if (data.pog.currency < cost) { - await client.gameserver.gameServerControllerSendMessage(data.gameServerId, { - message: 'You do not have enough currency to execute this command.', - opts: { - recipient: { + await gameserverService.sendMessage( + data.gameServerId, + 'You do not have enough currency to execute this command.', + new IMessageOptsDTO({ + recipient: new IPlayerReferenceDTO({ gameId: data.pog.gameId, - }, + }), + }), + ); + return; + } + } + + // Handle cooldown + const cooldown = commandsConfig[command?.name]?.cooldown; + const redisRes = (await redisClient.get(commandsRunningKey(data))) ?? '0'; + await redisClient.decr(commandsRunningKey(data)); + const commandsRunning = parseInt(redisRes, 10); + + if (cooldown) { + if (commandsRunning > 1) { + log.warn( + `Player ${data.player.id} tried to execute command ${data.itemId} but the command is already running ${commandsRunning} times`, + ); + + await gameserverService.sendMessage( + data.gameServerId, + 'You can only execute one command at a time. Please wait for the previous command to finish.', + new IMessageOptsDTO({ + recipient: new IPlayerReferenceDTO({ + gameId: data.pog.gameId, + }), + }), + ); + + return; + } + + const lastExecution = await eventService.metadataSearch( + { + filters: { + playerId: [data.player.id], + gameserverId: [data.gameServerId], + eventName: [EVENT_TYPES.COMMAND_EXECUTED], + }, + greaterThan: { + createdAt: new Date(Date.now() - cooldown * 1000), + }, + }, + [ + { + logicalOperator: 'AND', + filters: [{ field: 'command.id', operator: '=', value: data.itemId }], }, - }); + ], + ); + + if (lastExecution.results.length) { + log.warn( + `Player ${data.player.id} tried to execute command ${data.itemId} but the cooldown hasn't passed yet`, + ); + + const lastExecutionDate = new Date(lastExecution.results[0].createdAt); + const timeWhenCanExecute = new Date(lastExecutionDate.getTime() + cooldown * 1000); + + const gameserverService = new GameServerService(domainId); + await gameserverService.sendMessage( + data.gameServerId, + `This command can only be executed once every ${cooldown} seconds. You can execute it again at ${timeWhenCanExecute.toISOString()}`, + new IMessageOptsDTO({ + recipient: new IPlayerReferenceDTO({ gameId: data.pog.gameId }), + }), + ); return; } } diff --git a/packages/app-api/src/lib/systemConfig.ts b/packages/app-api/src/lib/systemConfig.ts index abff6dcdb8..55129c61bb 100644 --- a/packages/app-api/src/lib/systemConfig.ts +++ b/packages/app-api/src/lib/systemConfig.ts @@ -125,6 +125,13 @@ export function getSystemConfigSchema(mod: ModuleOutputDTO | ModuleOutputDTOApi) maximum: ms('1 day') / 1000, description: 'How many seconds to wait before executing the command.', }, + cooldown: { + type: 'number', + default: 0, + minimum: 0, + maximum: ms('7 days') / 1000, + description: 'How many seconds a player has to wait before executing the command again.', + }, cost: { type: 'number', default: 0, diff --git a/packages/app-api/src/service/CommandService.ts b/packages/app-api/src/service/CommandService.ts index 2f98570299..2700b8fc5f 100644 --- a/packages/app-api/src/service/CommandService.ts +++ b/packages/app-api/src/service/CommandService.ts @@ -4,11 +4,11 @@ import { CommandModel, CommandRepo } from '../db/command.js'; import { IsNumber, IsOptional, IsString, IsUUID, Length, ValidateNested } from 'class-validator'; import { FunctionCreateDTO, FunctionOutputDTO, FunctionService, FunctionUpdateDTO } from './FunctionService.js'; import { IMessageOptsDTO } from '@takaro/gameserver'; -import { IParsedCommand, queueService } from '@takaro/queues'; +import { ICommandJobData, IParsedCommand, queueService } from '@takaro/queues'; import { Type } from 'class-transformer'; import { TakaroDTO, errors, TakaroModelDTO, traceableClass } from '@takaro/util'; import { ICommand, ICommandArgument, EventChatMessage, ChatChannel } from '@takaro/modules'; -import { ITakaroQuery } from '@takaro/db'; +import { ITakaroQuery, Redis } from '@takaro/db'; import { PaginatedOutput } from '../db/base.js'; import { SettingsService, SETTINGS_KEYS } from './SettingsService.js'; import { parseCommand } from '../lib/commandParser.js'; @@ -18,6 +18,10 @@ import { PlayerOnGameServerService } from './PlayerOnGameserverService.js'; import { ModuleService } from './ModuleService.js'; import { UserService } from './UserService.js'; +export function commandsRunningKey(data: ICommandJobData) { + return `commands-running:${data.pog.id}`; +} + export class CommandOutputDTO extends TakaroModelDTO { @IsString() name: string; @@ -341,23 +345,25 @@ export class CommandService extends TakaroService): Promise { + const setupData = await modulesTestSetup.bind(this)(); + + await this.client.gameserver.gameServerControllerInstallModule(setupData.gameserver.id, setupData.utilsModule.id, { + systemConfig: JSON.stringify({ + commands: { + ping: { + cooldown: 60, + }, + }, + }), + }); + return setupData; +}; + +const tests = [ + new IntegrationTest({ + group, + snapshot: false, + setup: customSetup, + name: 'Returns error when player tries to execute command that is on cooldown', + test: async function () { + const events = (await new EventsAwaiter().connect(this.client)).waitForEvents(HookEvents.COMMAND_EXECUTED, 1); + await this.client.command.commandControllerTrigger(this.setupData.gameserver.id, { + msg: '/ping', + playerId: this.setupData.players[0].id, + }); + + expect((await events).length).to.be.eq(1); + + const events2 = (await new EventsAwaiter().connect(this.client)).waitForEvents(HookEvents.CHAT_MESSAGE, 1); + await this.client.command.commandControllerTrigger(this.setupData.gameserver.id, { + msg: '/ping', + playerId: this.setupData.players[0].id, + }); + + expect((await events2).length).to.be.eq(1); + expect((await events2)[0].data.meta.msg).to.match( + /This command can only be executed once every 60 seconds\. You can execute it again at /, + ); + }, + }), + new IntegrationTest({ + group, + snapshot: false, + setup: customSetup, + name: 'Handles cooldown when using commands in rapid succession', + test: async function () { + const events = (await new EventsAwaiter().connect(this.client)).waitForEvents(HookEvents.CHAT_MESSAGE, 2); + await Promise.all([ + this.client.command.commandControllerTrigger(this.setupData.gameserver.id, { + msg: '/ping', + playerId: this.setupData.players[0].id, + }), + this.client.command.commandControllerTrigger(this.setupData.gameserver.id, { + msg: '/ping', + playerId: this.setupData.players[0].id, + }), + ]); + + const sorted = (await events).sort((a, b) => a.data.meta.msg - b.data.meta.msg); + expect(sorted.length).to.be.eq(2); + expect(sorted[0].data.meta.msg).to.be.eq( + 'You can only execute one command at a time. Please wait for the previous command to finish.', + ); + expect(sorted[1].data.meta.msg).to.be.eq('Pong!'); + }, + }), +]; + +describe(group, function () { + tests.forEach((test) => { + test.run(); + }); +}); diff --git a/packages/test/src/__snapshots__/GameServerController/Get list of installed modules.json b/packages/test/src/__snapshots__/GameServerController/Get list of installed modules.json index cdac4ff04d..fa54dc5eee 100644 --- a/packages/test/src/__snapshots__/GameServerController/Get list of installed modules.json +++ b/packages/test/src/__snapshots__/GameServerController/Get list of installed modules.json @@ -13,11 +13,13 @@ "enabled": true, "commands": { "help": { + "cooldown": 0, "cost": 0, "delay": 0, "enabled": true }, "ping": { + "cooldown": 0, "cost": 0, "delay": 0, "enabled": true diff --git a/packages/test/src/__snapshots__/Module Assignments/Install a built-in module.json b/packages/test/src/__snapshots__/Module Assignments/Install a built-in module.json index 7e230b7f52..e0b3dca746 100644 --- a/packages/test/src/__snapshots__/Module Assignments/Install a built-in module.json +++ b/packages/test/src/__snapshots__/Module Assignments/Install a built-in module.json @@ -69,18 +69,20 @@ ], "functions": [], "permissions": [], - "systemConfigSchema": "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Enable/disable the module without having to uninstall it.\"},\"commands\":{\"type\":\"object\",\"properties\":{\"ping\":{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Enable the ping command.\"},\"delay\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"maximum\":86400,\"description\":\"How many seconds to wait before executing the command.\"},\"cost\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"description\":\"How much currency to deduct from the player before executing the command.\"},\"aliases\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Trigger the command with other names than the default\"}},\"required\":[],\"default\":{}},\"help\":{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Enable the help command.\"},\"delay\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"maximum\":86400,\"description\":\"How many seconds to wait before executing the command.\"},\"cost\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"description\":\"How much currency to deduct from the player before executing the command.\"},\"aliases\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Trigger the command with other names than the default\"}},\"required\":[],\"default\":{}}},\"required\":[],\"default\":{}}},\"required\":[\"commands\"],\"additionalProperties\":false}" + "systemConfigSchema": "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Enable/disable the module without having to uninstall it.\"},\"commands\":{\"type\":\"object\",\"properties\":{\"ping\":{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Enable the ping command.\"},\"delay\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"maximum\":86400,\"description\":\"How many seconds to wait before executing the command.\"},\"cooldown\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"maximum\":604800,\"description\":\"How many seconds a player has to wait before executing the command again.\"},\"cost\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"description\":\"How much currency to deduct from the player before executing the command.\"},\"aliases\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Trigger the command with other names than the default\"}},\"required\":[],\"default\":{}},\"help\":{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Enable the help command.\"},\"delay\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"maximum\":86400,\"description\":\"How many seconds to wait before executing the command.\"},\"cooldown\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"maximum\":604800,\"description\":\"How many seconds a player has to wait before executing the command again.\"},\"cost\":{\"type\":\"number\",\"default\":0,\"minimum\":0,\"description\":\"How much currency to deduct from the player before executing the command.\"},\"aliases\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Trigger the command with other names than the default\"}},\"required\":[],\"default\":{}}},\"required\":[],\"default\":{}}},\"required\":[\"commands\"],\"additionalProperties\":false}" }, "userConfig": {}, "systemConfig": { "enabled": true, "commands": { "help": { + "cooldown": 0, "cost": 0, "delay": 0, "enabled": true }, "ping": { + "cooldown": 0, "cost": 0, "delay": 0, "enabled": true