Skip to content

Conversation

@robertsmrek
Copy link

@robertsmrek robertsmrek commented Oct 19, 2025

This PR implements command warmups for EssentialsX, adding a configurable delay before commands are executed. During the warmup period, players must stand still and avoid taking damage, or the command will be cancelled.

This feature works similarly to the existing teleport warmup system but can be applied to any command, providing server administrators with better control over command execution timing.

Closes #1320

Configuration

# Command warmups: Set a delay (in seconds) before a command is executed.
# During the warmup period, the player must stand still (unless they have essentials.commandwarmups.move permission).
# If the player moves or takes damage during this period, the command is cancelled.
command-warmups:
  #home: 5 # 5-second warmup on /home command
  #back: 3 # 3-second warmup on /back command
  #afk: 3 # 3-second warmup on /afk command
  # Wildcards and regex patterns are supported (same as command cooldowns)

# Whether command warmups should persist across server shutdowns.
command-warmup-persistence: true

Features

  • Configurable delay before command execution
  • Movement cancellation (can be bypassed with essentials.commandwarmups.move permission)
  • Damage cancellation (cannot be bypassed)
  • Persistence across server restarts
  • Support for wildcards and regex patterns (e.g., tp*, /^home-.*/)
  • Customizable messages for warmup start, cancel, and completion
  • Permission-based warmup bypass: essentials.commandwarmups.bypass.<command>

Breaking Changes

None. This is a fully opt-in feature with no default warmups configured.

Test Coverage

The unit tests verify:

  • CommandWarmup entity creation and validation
  • Pattern matching (exact, wildcard, regex patterns)
  • Incomplete warmup detection and handling
  • Various warmup duration configurations
  • Pattern equality and string representation
  • Edge cases (null pattern, null value, complete warmup)

Production Validation

  • All warmup functionality (delays, cancellation on movement/damage, permissions, messages) operates as expected
  • No issues reported during real-world testing

@robertsmrek robertsmrek changed the title Command Warmups Resolves #1320 Command Warmups Oct 19, 2025
Copilot AI review requested due to automatic review settings December 3, 2025 12:37
Copilot finished reviewing on behalf of robertsmrek December 3, 2025 12:42
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a command warmup system for EssentialsX, allowing server administrators to configure delays before commands execute. The feature mirrors the existing teleport warmup functionality but applies to any command, with cancellation triggers for player movement and damage.

Key Changes:

  • Command warmup configuration with pattern matching support (wildcards and regex)
  • Asynchronous timer implementation (AsyncTimedCommand) to handle warmup delays and cancellation
  • Persistence system for warmups across server restarts
  • Permission-based bypass and movement allowance controls

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
CommandWarmupTest.java Unit tests for CommandWarmup entity validation and pattern matching
messages_en.properties Added three new message keys for warmup notifications (start, complete, cancelled)
config.yml Configuration documentation and examples for command warmup settings
CommandWarmupSerializer.java Serialization logic for persisting warmup data
UserConfigHolder.java Added warmup list storage to user timestamps
CommandWarmup.java Entity class defining warmup pattern and expiry value
EssentialsConfiguration.java Registered CommandWarmupSerializer for configuration handling
UserData.java CRUD operations for managing user warmup data
Settings.java Configuration parsing and warmup entry lookup with optimized List-based storage
IUser.java Interface additions for warmup management methods
ISettings.java Interface additions for warmup configuration queries
EssentialsPlayerListener.java Event handler integration for triggering warmups on command execution
AsyncTimedCommand.java Core warmup timer implementation with movement/damage detection and async command execution

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

private final String timer_command;
private final Pattern timer_pattern;
private final boolean timer_canMove;
private int timer_task;
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timer_task field is not marked as volatile, but it's accessed from multiple threads (async timer thread and sync cancellation). This could lead to visibility issues where one thread doesn't see the update from another thread. Consider marking it as volatile to ensure thread-safe visibility, similar to how it's handled in concurrent scenarios.

Suggested change
private int timer_task;
private volatile int timer_task;

Copilot uses AI. Check for mistakes.
commandArgumentOr=<secondary>
commandArgumentRequired=<yellow>
commandCooldown=<secondary>You cannot type that command for {0}.
commandWarmup=<secondary>Command will execute in {0}. Don''t move or take damage\!
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exclamation mark escape sequence \! is unnecessary in Java properties files. The backslash before ! should be removed as it's not a special character that needs escaping in this context. The correct format should be Don''t move or take damage!

Suggested change
commandWarmup=<secondary>Command will execute in {0}. Don''t move or take damage\!
commandWarmup=<secondary>Command will execute in {0}. Don''t move or take damage!

Copilot uses AI. Check for mistakes.

// Execute the command by dispatching it to the server
Bukkit.getScheduler().runTask(ess, () -> {
// Execute as server command to bypass the warmup check
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions "Execute as server command" but the code executes it as the player using Bukkit.dispatchCommand(user.getBase(), ...). This doesn't bypass permission checks as the comment suggests - the command is still executed as the player. If the intent is to bypass warmup checks, this is handled by clearing the warmup first, but the comment is misleading.

Suggested change
// Execute as server command to bypass the warmup check
// Execute the command as the player (not as server); warmup check is bypassed because it was cleared above

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +35
this.timer_initX = Math.round(user.getBase().getLocation().getX() * MOVE_CONSTANT);
this.timer_initY = Math.round(user.getBase().getLocation().getY() * MOVE_CONSTANT);
this.timer_initZ = Math.round(user.getBase().getLocation().getZ() * MOVE_CONSTANT);
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor accesses user.getBase().getLocation().getX() (and Y, Z) without first checking if the location is null. If a player's location is null at construction time (e.g., during server startup edge cases), this will throw a NullPointerException. Consider adding a null check before accessing location coordinates, similar to the check in the run() method at line 46.

Suggested change
this.timer_initX = Math.round(user.getBase().getLocation().getX() * MOVE_CONSTANT);
this.timer_initY = Math.round(user.getBase().getLocation().getY() * MOVE_CONSTANT);
this.timer_initZ = Math.round(user.getBase().getLocation().getZ() * MOVE_CONSTANT);
Location initLocation = user.getBase().getLocation();
if (initLocation == null) {
// Defensive: set to zero if location is null (could also throw or cancel)
this.timer_initX = 0;
this.timer_initY = 0;
this.timer_initZ = 0;
} else {
this.timer_initX = Math.round(initLocation.getX() * MOVE_CONSTANT);
this.timer_initY = Math.round(initLocation.getY() * MOVE_CONSTANT);
this.timer_initZ = Math.round(initLocation.getZ() * MOVE_CONSTANT);
}

Copilot uses AI. Check for mistakes.
private final Pattern timer_pattern;
private final boolean timer_canMove;
private int timer_task;
private double timer_health;
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timer_health field is not marked as volatile, but it's read and written from different threads (async timer thread in run() at line 76, and from the sync DelayedCommandTask). Without proper synchronization, one thread may not see updates made by another thread. Consider marking this field as volatile to ensure proper visibility across threads.

Suggested change
private double timer_health;
private volatile double timer_health;

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +96
// Execute as server command to bypass the warmup check
Bukkit.dispatchCommand(user.getBase(), timer_command.substring(1)); // Remove the leading '/'
});

user.sendTl("commandWarmupComplete");

} catch (final Exception ex) {
ess.showError(user.getSource(), ex, "\\ command warmup");
}
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warmup is cleared on line 84, but the message "commandWarmupComplete" is sent on line 92 after scheduling the command. If an exception occurs during command execution (line 87-90), the user will still see the "complete" message even though the command may have failed. Consider moving the success message inside the lambda on line 87, or handling exceptions more gracefully.

Suggested change
// Execute as server command to bypass the warmup check
Bukkit.dispatchCommand(user.getBase(), timer_command.substring(1)); // Remove the leading '/'
});
user.sendTl("commandWarmupComplete");
} catch (final Exception ex) {
ess.showError(user.getSource(), ex, "\\ command warmup");
}
try {
// Execute as server command to bypass the warmup check
Bukkit.dispatchCommand(user.getBase(), timer_command.substring(1)); // Remove the leading '/'
user.sendTl("commandWarmupComplete");
} catch (final Exception ex) {
ess.showError(user.getSource(), ex, "\\ command warmup");
}
});

Copilot uses AI. Check for mistakes.
Comment on lines +882 to +885
final int argStartIndex = effectiveCommand.indexOf(" ");
final String args = argStartIndex == -1 ? ""
: " " + effectiveCommand.substring(argStartIndex);
final String fullCommand = pluginCommand == null ? effectiveCommand : pluginCommand.getName() + args;
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] This code block for calculating args and fullCommand is duplicated from the command cooldowns section above (lines 836-839). Consider extracting this logic to avoid duplication and ensure consistency if the logic needs to change.

Copilot uses AI. Check for mistakes.
Comment on lines +873 to +881
if (ess.getSettings().isDebug()) {
ess.getLogger().info("Warmup check - enabled: " + ess.getSettings().isCommandWarmupsEnabled()
+ ", bypass: " + user.isAuthorized("essentials.commandwarmups.bypass")
+ ", command: " + effectiveCommand);
}

if (ess.getSettings().isCommandWarmupsEnabled()
&& !user.isAuthorized("essentials.commandwarmups.bypass")
&& (pluginCommand == null || !user.isAuthorized("essentials.commandwarmups.bypass." + pluginCommand.getName()))) {
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] This debug log message is always executed when debug mode is enabled, even if warmups are disabled or the user has bypass permissions. Consider moving this inside the warmup check condition (after line 881) to avoid unnecessary logging when warmups won't be processed.

Suggested change
if (ess.getSettings().isDebug()) {
ess.getLogger().info("Warmup check - enabled: " + ess.getSettings().isCommandWarmupsEnabled()
+ ", bypass: " + user.isAuthorized("essentials.commandwarmups.bypass")
+ ", command: " + effectiveCommand);
}
if (ess.getSettings().isCommandWarmupsEnabled()
&& !user.isAuthorized("essentials.commandwarmups.bypass")
&& (pluginCommand == null || !user.isAuthorized("essentials.commandwarmups.bypass." + pluginCommand.getName()))) {
if (ess.getSettings().isCommandWarmupsEnabled()
&& !user.isAuthorized("essentials.commandwarmups.bypass")
&& (pluginCommand == null || !user.isAuthorized("essentials.commandwarmups.bypass." + pluginCommand.getName()))) {
if (ess.getSettings().isDebug()) {
ess.getLogger().info("Warmup check - enabled: " + ess.getSettings().isCommandWarmupsEnabled()
+ ", bypass: " + user.isAuthorized("essentials.commandwarmups.bypass")
+ ", command: " + effectiveCommand);
}

Copilot uses AI. Check for mistakes.
private boolean isCustomNewUsernameMessage;
private List<String> spawnOnJoinGroups;
private Map<Pattern, Long> commandCooldowns;
private List<Entry<Pattern, Long>> commandWarmups; // Optimized: List is 19% faster than Map for sequential lookup
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The comment claims "List is 19% faster than Map for sequential lookup" but provides no context about the profiling methodology, data size, or conditions under which this was measured. For small lists this might be true, but for larger configuration files with many warmup entries, a Map would typically provide O(1) lookup vs O(n) for List. Consider documenting the benchmark conditions or reconsidering this optimization if the number of warmup entries could grow large.

Suggested change
private List<Entry<Pattern, Long>> commandWarmups; // Optimized: List is 19% faster than Map for sequential lookup
// Note: List was chosen over Map for commandWarmups due to observed ~19% faster sequential lookup in local profiling
// with fewer than 10 entries. For larger configuration files with many warmup entries, a Map would provide O(1) lookup
// vs O(n) for List. If the number of warmup entries is expected to grow, consider switching to a Map for scalability.
private List<Entry<Pattern, Long>> commandWarmups;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request: Command warmups

1 participant