Skip to content

Commit 5f3bce5

Browse files
committed
Simplify how player commands work in JavaScript
1 parent c93ad11 commit 5f3bce5

File tree

8 files changed

+226
-213
lines changed

8 files changed

+226
-213
lines changed

data/messages.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@
485485
"PLAYER_COMMANDS_SPAWN_WEAPONS_TELEPORT" : "@error Sorry, you can't get weapons now because you %s.",
486486
"PLAYER_COMMANDS_SPAWN_WEAPONS_TELEPORT_TARGET" : "@error Sorry, the player can't get weapons now because %s %s.",
487487
"PLAYER_COMMANDS_SPAWN_WEAPONS_WEAPON": "@success %s with ammo multiplier '%d' has been bought.",
488+
"PLAYER_COMMANDS_REQUIRES_VIP": "@error Sorry, this command is only available for VIPs. Check out /vip!",
488489

489490
"POSITIONING_CURRENT_POSITION": "Your current position is X: %d, Y: %d Z: %d, Rotation: %d",
490491
"POSITIONING_OTHER_USAGE_POS": "{FF9900}Other usage{FFFFFF}: /pos [x] [y] [z]",

javascript/features/player_commands/commands/spawn_weapons.js

Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,29 @@ import { WeaponData } from 'features/player_commands/weapon_data.js';
99
// Using the spawnweapons command is a bit more expensive than using the ammunation.
1010
const ExtraPriceFactor = 1.5;
1111

12-
// Command: spawnweapons [weaponId] [multipler]
13-
export default class SpawnWeapons extends PlayerCommand {
12+
// Implementation of the "/my spawnweapons" and "/p [player] spawnweapons" commands. Enables players
13+
// to purchase spawn weapons for the duration of their playing session.
14+
export class SpawnWeapons extends PlayerCommand {
1415
get name() { return 'spawnweapons'; }
15-
16-
setCommandParameters(commandBuilder) {
17-
return commandBuilder
18-
.parameters([
19-
{ name: 'weapon', type: CommandBuilder.NUMBER_PARAMETER },
20-
{ name: 'multiplier', type: CommandBuilder.NUMBER_PARAMETER }
21-
]);
22-
}
23-
24-
build(commandBuilder) {
25-
this.setCommandParameters(commandBuilder)
26-
.build(SpawnWeapons.prototype.onSpawnWeaponsCommand.bind(this));
16+
get parameters() {
17+
return [
18+
{ name: 'weapon', type: CommandBuilder.NUMBER_PARAMETER },
19+
{ name: 'multiplier', type: CommandBuilder.NUMBER_PARAMETER, defaultValue: 1 }
20+
];
2721
}
2822

29-
buildAdmin(commandBuilder) {
30-
this.setCommandParameters(commandBuilder)
31-
.build(SpawnWeapons.prototype.giveSpawnWeapon.bind(this));
32-
}
33-
34-
// Let the player buy spawn weapons
35-
onSpawnWeaponsCommand(player, weapon, multiplier) {
36-
this.giveSpawnWeapon(player, player, weapon, multiplier);
37-
}
23+
// Called when the command is executed by the |player| for the |target|, which may be another
24+
// player. The |weapon| and |multiplier| are guaranteed to be given.
25+
execute(player, target, weapon, multiplier) {
26+
const teleportStatus = this.limits().canTeleport(target);
3827

39-
// Gve the |subject| their |weapon| with |multiplier| * ammunations (from config).
40-
// If |player| is not |subject| an admin gives the weapon. Costs are not applied.
41-
giveSpawnWeapon(player, subject, weapon, multiplier) {
42-
const teleportStatus = this.limits_().canTeleport(subject);
4328
// Bail out if the |player| might abuse it.
4429
if (!teleportStatus.isApproved()) {
45-
if (player.id === subject.id) {
30+
if (player.id === target.id) {
4631
player.sendMessage(Message.PLAYER_COMMANDS_SPAWN_WEAPONS_TELEPORT, teleportStatus);
4732
} else {
4833
player.sendMessage(
49-
Message.PLAYER_COMMANDS_SPAWN_WEAPONS_TELEPORT_TARGET, subject.name,
34+
Message.PLAYER_COMMANDS_SPAWN_WEAPONS_TELEPORT_TARGET, target.name,
5035
teleportStatus);
5136
}
5237

@@ -65,21 +50,21 @@ export default class SpawnWeapons extends PlayerCommand {
6550

6651
const weaponData = WeaponData.getWeaponById(weapon);
6752

68-
if (player === subject) {
53+
if (player === target) {
6954
const price = weaponData.basePrice * multiplier * ExtraPriceFactor;
70-
if (this.finance_().getPlayerCash(player) < price) {
55+
if (this.finance().getPlayerCash(player) < price) {
7156
player.sendMessage(Message.PLAYER_COMMANDS_SPAWN_WEAPONS_NOT_ENOUGH_MONEY, price);
7257
return;
7358
}
7459

75-
this.finance_().takePlayerCash(player, price);
60+
this.finance().takePlayerCash(player, price);
7661
}
7762

7863
if (weaponData.id === 1337 /* armour */) {
79-
subject.giveSpawnArmour();
64+
target.giveSpawnArmour();
8065
player.sendMessage(Message.PLAYER_COMMANDS_SPAWN_WEAPONS_ARMOUR);
8166
} else {
82-
subject.giveSpawnWeapon(weaponData.id, multiplier);
67+
target.giveSpawnWeapon(weaponData.id, multiplier);
8368
player.sendMessage(
8469
Message.PLAYER_COMMANDS_SPAWN_WEAPONS_WEAPON, weaponData.name, multiplier);
8570
}

javascript/features/player_commands/commands/spawn_weapons.test.js

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,83 +2,75 @@
22
// Use of this source code is governed by the MIT license, a copy of which can
33
// be found in the LICENSE file.
44

5-
import SpawnWeapons from 'features/player_commands/commands/spawn_weapons.js';
6-
7-
describe('SpawnWeapons', (it, beforeEach, afterEach) => {
8-
let command = null;
5+
describe('SpawnWeapons', (it, beforeEach) => {
6+
let finance = null;
97
let gunther = null;
108
let russell = null;
11-
let finance = null;
129

1310
beforeEach(async () => {
14-
const announce = server.featureManager.loadFeature('announce');
15-
const limits = server.featureManager.loadFeature('limits');
11+
const feature = server.featureManager.loadFeature('player_commands');
1612

1713
finance = server.featureManager.loadFeature('finance');
14+
gunther = server.playerManager.getById(/* Gunther= */ 0);
15+
russell = server.playerManager.getById(/* Russell= */ 1);
16+
russell.level = Player.LEVEL_ADMINISTRATOR;
1817

19-
command = new SpawnWeapons(() => announce, () => finance, () => limits);
20-
21-
gunther = server.playerManager.getById(0 /* Gunther */);
22-
russell = server.playerManager.getById(1 /* Russell */);
23-
await gunther.identify();
18+
await feature.registry_.initialize();
2419
await russell.identify();
25-
russell.level = Player.LEVEL_ADMINISTRATOR;
2620
});
2721

28-
it('should not allow buying weapons if it might be abuse.', assert => {
22+
it('should not allow buying weapons if it might be abuse', async (assert) => {
2923
gunther.shoot({ target: russell });
3024

31-
command.onSpawnWeaponsCommand(gunther, 24, 1);
25+
assert.isTrue(await gunther.issueCommand('/my spawnweapons 24 1'));
3226

3327
assert.equal(gunther.messages.length, 1);
3428
assert.includes(gunther.messages[0], `Sorry, you can't get weapons now because you`);
3529
});
3630

37-
it('should not be able to give an invalid spawn weapon.', async assert => {
38-
command.giveSpawnWeapon(gunther, gunther, 35, 1);
31+
it('should not be able to give an invalid spawn weapon', async (assert) => {
32+
assert.isTrue(await gunther.issueCommand('/my spawnweapons 35'));
3933

4034
assert.equal(gunther.messages.length, 1);
4135
assert.includes(gunther.messages[0], 'Sorry, id 35 is not a valid spawn weapon.');
4236
});
4337

44-
it('should not be able to use zero as multiplier.', async assert => {
45-
command.giveSpawnWeapon(gunther, gunther, 24, 0);
38+
it('should not be able to use zero as multiplier', async (assert) => {
39+
assert.isTrue(await gunther.issueCommand('/my spawnweapons 24 0'));
4640

4741
assert.equal(gunther.messages.length, 1);
4842
assert.includes(gunther.messages[0], 'Sorry, you can only have a multiplier of 1-100.');
49-
});
5043

51-
it('should not be able to use 101 as multiplier.', async assert => {
52-
command.giveSpawnWeapon(gunther, gunther, 24, 101);
44+
assert.isTrue(await gunther.issueCommand('/my spawnweapons 24 101'));
5345

54-
assert.equal(gunther.messages.length, 1);
55-
assert.includes(gunther.messages[0], 'Sorry, you can only have a multiplier of 1-100.');
46+
assert.equal(gunther.messages.length, 2);
47+
assert.includes(gunther.messages[1], 'Sorry, you can only have a multiplier of 1-100.');
5648
});
5749

58-
it('should give error if player does not have enough cash.', async assert => {
59-
command.giveSpawnWeapon(gunther, gunther, 24, 10);
50+
it('should give error if player does not have enough cash', async (assert) => {
51+
assert.isTrue(await gunther.issueCommand('/my spawnweapons 24'));
6052

6153
assert.equal(gunther.messages.length, 1);
6254
assert.includes(gunther.messages[0], 'Sorry, you need');
6355
});
6456

6557
it('should be able to give spawn armour to other player without money.', async assert => {
66-
command.giveSpawnWeapon(russell, gunther, 1337, 1);
58+
assert.isTrue(await russell.issueCommand('/p gunther spawnweapons 1337'));
6759

6860
assert.equal(russell.messages.length, 1);
6961
assert.equal(
7062
russell.messages[0], Message.format(Message.PLAYER_COMMANDS_SPAWN_WEAPONS_ARMOUR));
63+
7164
assert.equal(gunther.messages.length, 0);
7265
});
7366

74-
it('should give spawn weapon if everything aligns nicely', async assert => {
67+
it('should give spawn weapon if everything aligns nicely', async (assert) => {
7568
finance.givePlayerCash(gunther, 1000000);
7669

77-
command.onSpawnWeaponsCommand(gunther, 24, 1);
70+
assert.isTrue(await gunther.issueCommand('/my spawnweapons 24'));
7871

7972
assert.equal(gunther.messages.length, 1);
8073
assert.includes(
8174
gunther.messages[0], `Desert Eagle with ammo multiplier '1' has been bought.`);
8275
});
83-
8476
});

javascript/features/player_commands/player_command.js

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,60 @@
22
// Use of this source code is governed by the MIT license, a copy of which can
33
// be found in the LICENSE file.
44

5-
// The playerCommand can be used by using /my [playerCommand] [params]
5+
// Represents a player-bound command. Accessible using either "/my [name]" or "/p [player] [name]"
6+
// when the player executing the command is an administrator. Has access to various dependencies.
67
export class PlayerCommand {
8+
#announce_ = null;
9+
#finance_ = null;
10+
#limits_ = null;
11+
712
constructor(announce, finance, limits) {
8-
this.announce_ = announce;
9-
this.finance_ = finance;
10-
this.limits_ = limits;
13+
this.#announce_ = announce;
14+
this.#finance_ = finance;
15+
this.#limits_ = limits;
1116
}
1217

13-
// Gets the name of the current command. Must be implemented by the command.
14-
get name() {
15-
throw new Error('Command::name getter must be implemented by the command.');
16-
}
18+
// Gets read-only access to the dependencies available to commands.
19+
get announce() { return this.#announce_; }
20+
get finance() { return this.#finance_; }
21+
get limits() { return this.#limits_; }
1722

18-
// Builds the command based on |commandBuilder|. Must be implemented by the command.
19-
build(commandBuilder) {
20-
throw new Error('Command::build() must be implemented by the command: /' + this.name);
21-
}
23+
// ---------------------------------------------------------------------------------------------
24+
// Required API to be implemented by individual commands.
25+
// ---------------------------------------------------------------------------------------------
26+
27+
// Gets the name of the command ("/{name}").
28+
get name() { throw new Error('PlayerCommand::name is expected to be implemented.'); }
29+
30+
// Executes the command, which has been invoked by the |player|, for the |target|. The |target|
31+
// may either be the |player| (when /my is used), or another player through administrator use.
32+
execute(player, target) {}
33+
34+
// ---------------------------------------------------------------------------------------------
35+
// Optional API to be implemented by individual commands.
36+
// ---------------------------------------------------------------------------------------------
37+
38+
// Gets the required level for this command to be available through the "/p [player]" command.
39+
get administratorLevel() { return Player.LEVEL_ADMINISTRATOR; }
2240

23-
// Build the admin version o the command based on |commandBuilder|
24-
buildAdmin(commandBuilder) { }
41+
// Gets the parameters (& default values) available when players run this command.
42+
get parameters() { return []; }
2543

26-
dispose() {}
44+
// Gets the required level for this command to be available through the "/my" command.
45+
get playerLevel() { return Player.LEVEL_PLAYER; }
46+
47+
// Gets whether players have to be VIP in order to be able to execute this command.
48+
get playerVip() { return false; }
49+
50+
// ---------------------------------------------------------------------------------------------
51+
52+
toString() { return `[object PlayerCommand("/${this.name}")]`; }
53+
54+
// ---------------------------------------------------------------------------------------------
55+
56+
dispose() {
57+
this.#announce_ = null;
58+
this.#finance_ = null;
59+
this.#limits_ = null;
60+
}
2761
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright 2020 Las Venturas Playground. All rights reserved.
2+
// Use of this source code is governed by the MIT license, a copy of which can
3+
// be found in the LICENSE file.
4+
5+
import { CommandBuilder } from 'components/command_manager/command_builder.js';
6+
import { PlayerCommand } from 'features/player_commands/player_command.js';
7+
8+
// The directory in which the command definitions are located, and the include path through which
9+
// we can import them to JavaScript with dynamic import statements.
10+
const kCommandDirectory = 'javascript/features/player_commands/commands/';
11+
const kCommandIncludePath = 'features/player_commands/commands/';
12+
13+
// Loads and keeps track of the sub-commands available to "/my" and "/p". Generates help messages
14+
// tailored to particular players with the functionality available to them.
15+
export class PlayerCommandRegistry {
16+
#commands_ = null;
17+
#params_ = null;
18+
19+
constructor(...params) {
20+
this.#commands_ = new Set();
21+
this.#params_ = params;
22+
}
23+
24+
// Asynchronously loads all defined commands, and builds the "/my" and "/p" commands on the
25+
// server. Has to be called explicitly when running tests.
26+
async initialize() {
27+
// (1) Build the set of player commands supported by Las Venturas Playground.
28+
for (const filename of glob(kCommandDirectory, '^((?!test).)*\.js$')) {
29+
const exports = await import(kCommandIncludePath + filename);
30+
const constructor = Object.values(exports).pop();
31+
32+
const command = new constructor(...this.#params_);
33+
if (!(command instanceof PlayerCommand))
34+
throw new Error(`Source file "${filename}" does not export a player command.`);
35+
36+
this.#commands_.add(command);
37+
}
38+
39+
// (2) Initialize the builders through which the individual commands will be registered.
40+
const myBuilder = server.commandManager.buildCommand('my');
41+
const playerBuilder = server.commandManager.buildCommand('p')
42+
.sub(CommandBuilder.PLAYER_PARAMETER);
43+
44+
// (3) Iterate over all the commands, and add them to both "/my" and "/p".
45+
for (const command of this.#commands_) {
46+
myBuilder.sub(command.name)
47+
.restrict(command.playerLevel)
48+
.parameters(command.parameters)
49+
.build(PlayerCommandRegistry.prototype.onCommand.bind(
50+
this, command, /* subject= */ null));
51+
52+
playerBuilder.sub(command.name)
53+
.restrict(command.administratorLevel)
54+
.parameters(command.parameters)
55+
.build(PlayerCommandRegistry.prototype.onCommand.bind(this, command));
56+
}
57+
58+
// (4) Register the "/my" command with the server. We already closed the last sub-command.
59+
myBuilder.build(PlayerCommandRegistry.prototype.onMyCommand.bind(this));
60+
61+
// (5) Register the "/p" command with the server. The sub-command requiring the player
62+
// parameter is still open, so that has to be closed first.
63+
playerBuilder
64+
.build(PlayerCommandRegistry.prototype.onPlayerCommand.bind(this))
65+
.build(PlayerCommandRegistry.prototype.onPlayerCommand.bind(this));
66+
}
67+
68+
// ---------------------------------------------------------------------------------------------
69+
70+
// Called when the |player| has executed the |command|, optionally for |subject| when they are
71+
// an administrator and have sufficient access. The |params| should be passed through.
72+
async onCommand(command, subject, player, ...params) {
73+
const self = subject ?? player;
74+
75+
// Bail out if the |player| is using the command on themselves, it requires VIP access and
76+
// they don't have VIP access. Administrators cannot cheat this way either.
77+
if (self === player && command.playerVip && !player.isVip()) {
78+
player.sendMessage(Message.PLAYER_COMMANDS_REQUIRES_VIP);
79+
return;
80+
}
81+
82+
// Execute the |command| and wait for it to be complete.
83+
return await command.execute(self, player, ...params);
84+
}
85+
86+
// Called when the |player| has executed the "/my" command with parameters that could not be
87+
// fully understood, and thus should be displayed a help message.
88+
onMyCommand(player, params) {
89+
// TODO: Migrate all the player commands to JavaScript, then display a help message here.
90+
wait(0).then(() => pawnInvoke(
91+
'OnPlayerCommand', 'is', player.id, '/my ' + params ?? ''));
92+
}
93+
94+
// Called when the |player| has executed the "/p" command with parameters that could not be
95+
// fully understood, and thus has to be displayed a help message.
96+
onPlayerCommand(player, params) {
97+
// TODO: Migrate all the player commands to JavaScript, then display a help message here.
98+
wait(0).then(() => pawnInvoke(
99+
'OnPlayerCommand', 'is', player.id, '/p ' + params ?? ''));
100+
}
101+
102+
// ---------------------------------------------------------------------------------------------
103+
104+
dispose() {
105+
for (const command of this.#commands_)
106+
command.dispose();
107+
108+
this.#commands_.clear();
109+
this.#commands_ = null;
110+
111+
this.#params_ = null;
112+
113+
if (!server.commandManager.hasCommand('my'))
114+
return; // the commands never were registered
115+
116+
server.commandManager.removeCommand('my');
117+
server.commandManager.removeCommand('p');
118+
}
119+
}

0 commit comments

Comments
 (0)