Skip to content

Commit

Permalink
Merge pull request #1192 from gettakaro/issue1024
Browse files Browse the repository at this point in the history
  • Loading branch information
niekcandaele authored Aug 30, 2024
2 parents 24b4edd + 9fb9632 commit 493c2b8
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 27 deletions.
84 changes: 77 additions & 7 deletions packages/app-api/src/executors/executeFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, RateLimiterRedis> = new Map();

Expand Down Expand Up @@ -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: {
Expand All @@ -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();
Expand All @@ -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<string, any>;
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;
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/app-api/src/lib/systemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 25 additions & 19 deletions packages/app-api/src/service/CommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<CommandOutputDTO> {
@IsString()
name: string;
Expand Down Expand Up @@ -341,23 +345,25 @@ export class CommandService extends TakaroService<CommandModel, CommandOutputDTO
);
}

return queueService.queues.commands.queue.add(
{
timestamp: data.timestamp,
domainId: this.domainId,
functionId: db.function.id,
itemId: db.id,
pog,
arguments: data.arguments,
module: data.module,
gameServerId,
player,
user,
chatMessage,
trigger: commandName,
},
{ delay },
);
const jobData = {
timestamp: data.timestamp,
domainId: this.domainId,
functionId: db.function.id,
itemId: db.id,
pog,
arguments: data.arguments,
module: data.module,
gameServerId,
player,
user,
chatMessage,
trigger: commandName,
};

const redisClient = await Redis.getClient('worker:command-lock');
await redisClient.incr(commandsRunningKey(jobData));

return queueService.queues.commands.queue.add(jobData, { delay });
});

await Promise.all(promises);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { IntegrationTest, expect, IModuleTestsSetupData, modulesTestSetup, EventsAwaiter } from '@takaro/test';
import { HookEvents } from '../dto/index.js';

const group = 'System config - cooldown';

const customSetup = async function (this: IntegrationTest<IModuleTestsSetupData>): Promise<IModuleTestsSetupData> {
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<IModuleTestsSetupData>({
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<IModuleTestsSetupData>({
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 493c2b8

Please sign in to comment.