feat(pumpkin): add /save-all, /save-off and /save-on commands (#2394)

Implements the three vanilla save-control commands (tracked in #15):

- /save-all saves all online players' data and advancements, then
  requests a chunk save from every world's chunk scheduler. It works
  even while autosaving is disabled, matching Vanilla.
- /save-off disables periodic autosaving via a new save_enabled flag
  on Level; running it again fails with commands.save.alreadyOff.
- /save-on re-enables autosaving; running it again fails with
  commands.save.alreadyOn.
This commit is contained in:
Mcxiaocaibug
2026-07-13 23:40:23 +08:00
committed by GitHub
parent 4929c590ab
commit 1f38ba8054
6 changed files with 240 additions and 1 deletions

View File

@@ -91,6 +91,9 @@ pub struct Level {
pub shut_down_chunk_system: AtomicBool,
pub should_save: AtomicBool,
pub should_unload: AtomicBool,
/// Whether periodic autosaving is enabled. Toggled by `/save-off` and `/save-on`;
/// a manual `/save-all` still saves while this is `false`.
pub save_enabled: AtomicBool,
/// Number of ticks between autosave checks. If 0, autosave is disabled.
pub autosave_ticks: u64,
@@ -262,6 +265,7 @@ impl Level {
shut_down_chunk_system: AtomicBool::new(false),
should_save: AtomicBool::new(false),
should_unload: AtomicBool::new(false),
save_enabled: AtomicBool::new(true),
autosave_ticks: level_config.autosave_ticks,
pending_entity_generations,
level_channel: level_channel.clone(),

View File

@@ -40,6 +40,9 @@ mod plugins;
mod pumpkin;
mod random;
mod rotate;
mod saveall;
mod saveoff;
mod saveon;
mod say;
mod seed;
mod setblock;
@@ -156,6 +159,9 @@ pub async fn default_dispatcher(
random::register(&mut dispatcher, registry);
list::register(&mut dispatcher, registry);
seed::register(&mut dispatcher, registry);
saveall::register(&mut dispatcher, registry);
saveoff::register(&mut dispatcher, registry);
saveon::register(&mut dispatcher, registry);
setidletimeout::register(&mut dispatcher, registry);
spreadplayers::register(&mut dispatcher, registry);
stop::register(&mut dispatcher, registry);

View File

@@ -0,0 +1,93 @@
use std::sync::atomic::Ordering::Relaxed;
use pumpkin_data::translation;
use pumpkin_util::PermissionLvl;
use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry};
use pumpkin_util::text::TextComponent;
use tracing::error;
use crate::command::argument_builder::{ArgumentBuilder, command};
use crate::command::context::command_context::CommandContext;
use crate::command::errors::error_types::CommandErrorType;
use crate::command::node::dispatcher::CommandDispatcher;
use crate::command::node::{CommandExecutor, CommandExecutorResult};
const DESCRIPTION: &str = "Saves the server to disk.";
const PERMISSION: &str = "minecraft:command.save-all";
const SAVE_FAILED_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_SAVE_FAILED,
translation::bedrock::COMMANDS_SAVE_FAILED,
);
struct SaveAllExecutor;
impl CommandExecutor for SaveAllExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_SAVE_SAVING,
translation::bedrock::COMMANDS_SAVE_START,
[],
),
false,
)
.await;
let server = context.server();
if let Err(err) = server.player_data_storage.save_all_players(server).await {
error!("Failed to save player data: {err}");
return Err(SAVE_FAILED_ERROR_TYPE.create_without_context());
}
if let Err(err) = server
.advancement_manager
.save_all_players(&server.get_all_players())
.await
{
error!("Failed to save player advancements: {err}");
return Err(SAVE_FAILED_ERROR_TYPE.create_without_context());
}
// Request a save from every world's chunk scheduler. This works even
// while autosaving is disabled via /save-off, matching Vanilla.
for world in server.worlds.load().iter() {
world.level.should_save.store(true, Relaxed);
world.level.level_channel.notify();
}
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_SAVE_SUCCESS,
translation::bedrock::COMMANDS_SAVE_SUCCESS,
[],
),
true,
)
.await;
Ok(1)
})
}
}
pub fn register(dispatcher: &mut CommandDispatcher, registry: &mut PermissionRegistry) {
registry.register_permission_or_panic(Permission::new(
PERMISSION,
DESCRIPTION,
PermissionDefault::Op(PermissionLvl::Four),
));
dispatcher.register(
command("save-all", DESCRIPTION)
.requires(PERMISSION)
.executes(SaveAllExecutor),
);
}

View File

@@ -0,0 +1,68 @@
use std::sync::atomic::Ordering::Relaxed;
use pumpkin_data::translation;
use pumpkin_util::PermissionLvl;
use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry};
use pumpkin_util::text::TextComponent;
use crate::command::argument_builder::{ArgumentBuilder, command};
use crate::command::context::command_context::CommandContext;
use crate::command::errors::error_types::CommandErrorType;
use crate::command::node::dispatcher::CommandDispatcher;
use crate::command::node::{CommandExecutor, CommandExecutorResult};
const DESCRIPTION: &str = "Disables automatic server saves.";
const PERMISSION: &str = "minecraft:command.save-off";
const ALREADY_OFF_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_SAVE_ALREADYOFF,
translation::bedrock::COMMANDS_SAVE_OFF_ALREADYOFF,
);
struct SaveOffExecutor;
impl CommandExecutor for SaveOffExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let mut any_disabled = false;
for world in context.server().worlds.load().iter() {
if world.level.save_enabled.swap(false, Relaxed) {
any_disabled = true;
}
}
if !any_disabled {
return Err(ALREADY_OFF_ERROR_TYPE.create_without_context());
}
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_SAVE_DISABLED,
translation::bedrock::COMMANDS_SAVE_DISABLED,
[],
),
true,
)
.await;
Ok(1)
})
}
}
pub fn register(dispatcher: &mut CommandDispatcher, registry: &mut PermissionRegistry) {
registry.register_permission_or_panic(Permission::new(
PERMISSION,
DESCRIPTION,
PermissionDefault::Op(PermissionLvl::Four),
));
dispatcher.register(
command("save-off", DESCRIPTION)
.requires(PERMISSION)
.executes(SaveOffExecutor),
);
}

View File

@@ -0,0 +1,68 @@
use std::sync::atomic::Ordering::Relaxed;
use pumpkin_data::translation;
use pumpkin_util::PermissionLvl;
use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry};
use pumpkin_util::text::TextComponent;
use crate::command::argument_builder::{ArgumentBuilder, command};
use crate::command::context::command_context::CommandContext;
use crate::command::errors::error_types::CommandErrorType;
use crate::command::node::dispatcher::CommandDispatcher;
use crate::command::node::{CommandExecutor, CommandExecutorResult};
const DESCRIPTION: &str = "Enables automatic server saves.";
const PERMISSION: &str = "minecraft:command.save-on";
const ALREADY_ON_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_SAVE_ALREADYON,
translation::bedrock::COMMANDS_SAVE_ON_ALREADYON,
);
struct SaveOnExecutor;
impl CommandExecutor for SaveOnExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let mut any_enabled = false;
for world in context.server().worlds.load().iter() {
if !world.level.save_enabled.swap(true, Relaxed) {
any_enabled = true;
}
}
if !any_enabled {
return Err(ALREADY_ON_ERROR_TYPE.create_without_context());
}
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_SAVE_ENABLED,
translation::bedrock::COMMANDS_SAVE_ENABLED,
[],
),
true,
)
.await;
Ok(1)
})
}
}
pub fn register(dispatcher: &mut CommandDispatcher, registry: &mut PermissionRegistry) {
registry.register_permission_or_panic(Permission::new(
PERMISSION,
DESCRIPTION,
PermissionDefault::Op(PermissionLvl::Four),
));
dispatcher.register(
command("save-on", DESCRIPTION)
.requires(PERMISSION)
.executes(SaveOnExecutor),
);
}

View File

@@ -1132,7 +1132,7 @@ impl World {
}
}
}
if self.level.autosave_ticks > 0 {
if self.level.autosave_ticks > 0 && self.level.save_enabled.load(Relaxed) {
let autosave = self.level.autosave_ticks as i64;
if autosave > 0 && level_time.world_age % autosave == 0 {
self.level.should_save.store(true, Relaxed);