diff --git a/README.md b/README.md index 6efde15..7ed1f33 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ## 项目定位 - 最低支持版本为 `Paper 1.21.11` -- 已适配 `Paper 26.1.2` +- 已适配 `Paper 26.2` - 构建环境固定为 `Java 21` - 配置、模块开关与文本分离:行为配置放在 `config.yml`,模块开关放在 `modules.yml`,提示文本放在 `lang/` - 支持运行期模块开关,避免为不同功能组合构建多个插件版本 @@ -176,6 +176,7 @@ IDEA 运行配置会在启动测试服前自动执行对应部署任务。部署 - 使用 `paperweight-userdev` 进行 Paper 开发 - 使用 Paper Lifecycle Command API 注册命令,避免直接反射 Bukkit CommandMap +- 涉及 NMS 的功能通过 `compat-api` + `versions/v<版本>` 适配模块隔离,主插件只依赖稳定接口;当前包含 `1.21.11`、`26.1.2`、`26.2` - 运行时通过 `modules.yml` 控制模块加载,命令与监听器按模块状态注册 - 发布流程基于 GitHub Actions 和 Gradle Wrapper diff --git a/build.gradle b/build.gradle index da956b4..06acde8 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'cn.infstar' -version = '1.5.0' +version = '1.6.0' repositories { mavenCentral() @@ -20,12 +20,15 @@ repositories { dependencies { paperweight.paperDevBundle('1.21.11-R0.1-SNAPSHOT') + implementation project(':compat-api') } java { toolchain.languageVersion.set(JavaLanguageVersion.of(21)) } +paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArtifactConfiguration.getMOJANG_PRODUCTION() + tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } @@ -50,6 +53,16 @@ tasks.named('shadowJar', ShadowJar).configure { description = '构建包含全部运行期可开关模块的 EssentialsC 插件。' archiveFileName.set("EssentialsC-${project.version}.jar") configurations = [project.configurations.runtimeClasspath] + + [ + project(':versions:v1_21_11'), + project(':versions:v26_1_2'), + project(':versions:v26_2') + ].each { adapterProject -> + def adapterJar = adapterProject.tasks.named('jar') + dependsOn(adapterJar) + from(adapterJar.flatMap { it.archiveFile }.map { archiveFile -> zipTree(archiveFile) }) + } } tasks.named('assemble').configure { @@ -77,3 +90,4 @@ def registerTestServerDeployTask = { String taskName, String serverPath, String registerTestServerDeployTask('deployToPaper12111', 'test-server/paper-1.21.11', 'Paper 1.21.11') registerTestServerDeployTask('deployToPaper2612', 'test-server/paper-26.1.2', 'Paper 26.1.2') +registerTestServerDeployTask('deployToPaper262', 'test-server/paper-26.2', 'Paper 26.2') diff --git a/compat-api/build.gradle b/compat-api/build.gradle new file mode 100644 index 0000000..34a58c1 --- /dev/null +++ b/compat-api/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'java-library' +} + +repositories { + mavenCentral() + maven { + name = 'papermc' + url = uri('https://repo.papermc.io/repository/maven-public/') + } +} + +dependencies { + compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} diff --git a/compat-api/src/main/java/cn/infstar/essentialsC/compat/jei/JeiRecipeSyncAdapter.java b/compat-api/src/main/java/cn/infstar/essentialsC/compat/jei/JeiRecipeSyncAdapter.java new file mode 100644 index 0000000..060f546 --- /dev/null +++ b/compat-api/src/main/java/cn/infstar/essentialsC/compat/jei/JeiRecipeSyncAdapter.java @@ -0,0 +1,12 @@ +package cn.infstar.essentialsC.compat.jei; + +import org.bukkit.entity.Player; + +import java.util.logging.Logger; + +public interface JeiRecipeSyncAdapter { + + void sendFabricRecipeSync(Player player, Logger logger, boolean debug) throws Exception; + + void sendNeoForgeRecipeSync(Player player, Logger logger, boolean debug) throws Exception; +} diff --git a/settings.gradle b/settings.gradle index 7d33bac..feb3aa0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,4 +15,13 @@ pluginManagement { } } +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + rootProject.name = 'EssentialsC' + +include 'compat-api' +include 'versions:v1_21_11' +include 'versions:v26_1_2' +include 'versions:v26_2' diff --git a/src/main/java/cn/infstar/essentialsC/EssentialsC.java b/src/main/java/cn/infstar/essentialsC/EssentialsC.java index 9242a9c..034b12b 100644 --- a/src/main/java/cn/infstar/essentialsC/EssentialsC.java +++ b/src/main/java/cn/infstar/essentialsC/EssentialsC.java @@ -13,6 +13,7 @@ import cn.infstar.essentialsC.listeners.ShulkerBoxListener; import cn.infstar.essentialsC.listeners.VanishListener; import cn.infstar.essentialsC.maintenance.MaintenanceListener; import cn.infstar.essentialsC.maintenance.MaintenanceManager; +import cn.infstar.essentialsC.teleport.TeleportRequestManager; import cn.infstar.essentialsC.tpsbar.TpsBarManager; import cn.infstar.essentialsC.tpsbar.TpsBarService; import org.bukkit.command.CommandSender; @@ -36,6 +37,7 @@ public final class EssentialsC extends JavaPlugin { private ModuleManager moduleManager; private AdminModeManager adminModeManager; private MaintenanceManager maintenanceManager; + private TeleportRequestManager teleportRequestManager; private MaintenanceListener maintenanceListener; private TpsBarService tpsBarManager; private ShulkerBoxListener shulkerBoxListener; @@ -68,6 +70,9 @@ public final class EssentialsC extends JavaPlugin { if (maintenanceManager != null) { maintenanceManager.shutdown(); } + if (teleportRequestManager != null) { + teleportRequestManager.shutdown(); + } VanishCommand.clearAll(this); unregisterRuntimeListeners(); unregisterPluginChannels(); @@ -90,6 +95,10 @@ public final class EssentialsC extends JavaPlugin { return maintenanceManager; } + public TeleportRequestManager getTeleportRequestManager() { + return teleportRequestManager; + } + public TpsBarService getTpsBarManager() { return tpsBarManager; } @@ -107,6 +116,11 @@ public final class EssentialsC extends JavaPlugin { private void refreshPlayer() { if (!moduleManager.isEnabled(ModuleManager.PLAYER)) { + if (teleportRequestManager != null) { + teleportRequestManager.shutdown(); + HandlerList.unregisterAll(teleportRequestManager); + teleportRequestManager = null; + } VanishCommand.clearAll(this); if (vanishListener != null) { HandlerList.unregisterAll(vanishListener); @@ -120,6 +134,12 @@ public final class EssentialsC extends JavaPlugin { vanishListener = new VanishListener(this); getServer().getPluginManager().registerEvents(vanishListener, this); } + if (teleportRequestManager == null) { + teleportRequestManager = new TeleportRequestManager(this); + getServer().getPluginManager().registerEvents(teleportRequestManager, this); + } else { + teleportRequestManager.reload(); + } setModuleStatus("玩家功能", true, "命令可用"); } @@ -278,6 +298,7 @@ public final class EssentialsC extends JavaPlugin { unregisterListener(mobDropListener); unregisterListener(mobDropMenuListener); unregisterListener(vanishListener); + unregisterListener(teleportRequestManager); if (tpsBarManager instanceof Listener listener) { unregisterListener(listener); } diff --git a/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java b/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java index 64d9e2c..148d9c4 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java +++ b/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java @@ -40,6 +40,12 @@ public final class CommandRegistry { register("seen", "essentialsc.command.seen", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.SeenCommand", "info"); register("feed", "essentialsc.command.feed", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.FeedCommand"); register("repair", "essentialsc.command.repair", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.RepairCommand", "rep"); + register("tpa", "essentialsc.command.tpa", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpaCommand"); + register("tpahere", "essentialsc.command.tpahere", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpaHereCommand"); + register("tpaall", "essentialsc.command.tpaall", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpaAllCommand"); + register("tpaccept", "essentialsc.command.tpaccept", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpAcceptCommand", "tpyes"); + register("tpdeny", "essentialsc.command.tpdeny", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpDenyCommand", "tpdecline", "tpno"); + register("tpignore", "essentialsc.command.tpignore", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpIgnoreCommand"); register("tpsbar", "essentialsc.command.tpsbar", ModuleManager.TPSBAR, "cn.infstar.essentialsC.commands.TpsBarCommand"); register("mobdrops", "essentialsc.mobdrops.enderman", ModuleManager.MOB_DROPS, "cn.infstar.essentialsC.commands.MobDropCommand"); register("maintenance", "essentialsc.command.maintenance", ModuleManager.MAINTENANCE, "cn.infstar.essentialsC.commands.MaintenanceCommand", "maint"); diff --git a/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java b/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java index 508c2d4..a53be82 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java +++ b/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java @@ -2,6 +2,7 @@ package cn.infstar.essentialsC.commands; import cn.infstar.essentialsC.EssentialsC; import cn.infstar.essentialsC.LangManager; +import cn.infstar.essentialsC.teleport.TeleportRequestManager; import org.bukkit.Bukkit; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; @@ -177,6 +178,30 @@ public class HelpCommand extends BaseCommand implements TabCompleter { otherCommands.append(lang.getString("help.commands.seen")).append("\n"); hasOtherCommands = true; } + if (CommandRegistry.isAvailable("tpa") && player.hasPermission("essentialsc.command.tpa")) { + otherCommands.append(lang.getString("help.commands.tpa")).append("\n"); + hasOtherCommands = true; + } + if (CommandRegistry.isAvailable("tpahere") && player.hasPermission("essentialsc.command.tpahere")) { + otherCommands.append(lang.getString("help.commands.tpahere")).append("\n"); + hasOtherCommands = true; + } + if (CommandRegistry.isAvailable("tpaall") && player.hasPermission("essentialsc.command.tpaall")) { + otherCommands.append(lang.getString("help.commands.tpaall")).append("\n"); + hasOtherCommands = true; + } + if (CommandRegistry.isAvailable("tpaccept") && player.hasPermission("essentialsc.command.tpaccept")) { + otherCommands.append(lang.getString("help.commands.tpaccept")).append("\n"); + hasOtherCommands = true; + } + if (CommandRegistry.isAvailable("tpdeny") && player.hasPermission("essentialsc.command.tpdeny")) { + otherCommands.append(lang.getString("help.commands.tpdeny")).append("\n"); + hasOtherCommands = true; + } + if (CommandRegistry.isAvailable("tpignore") && player.hasPermission("essentialsc.command.tpignore")) { + otherCommands.append(lang.getString("help.commands.tpignore")).append("\n"); + hasOtherCommands = true; + } if (CommandRegistry.isAvailable("admin") && player.hasPermission("essentialsc.command.admin")) { otherCommands.append(lang.getString("help.commands.admin")).append("\n"); hasOtherCommands = true; @@ -259,6 +284,15 @@ public class HelpCommand extends BaseCommand implements TabCompleter { {"feed", "essentialsc.command.feed"}, {"repair", "essentialsc.command.repair"}, {"rep", "essentialsc.command.repair"}, + {"tpa", "essentialsc.command.tpa"}, + {"tpahere", "essentialsc.command.tpahere"}, + {"tpaall", "essentialsc.command.tpaall"}, + {"tpaccept", "essentialsc.command.tpaccept"}, + {"tpyes", "essentialsc.command.tpaccept"}, + {"tpdeny", "essentialsc.command.tpdeny"}, + {"tpdecline", "essentialsc.command.tpdeny"}, + {"tpno", "essentialsc.command.tpdeny"}, + {"tpignore", "essentialsc.command.tpignore"}, {"tpsbar", "essentialsc.command.tpsbar"}, {"maintenance", "essentialsc.command.maintenance"}, {"maint", "essentialsc.command.maintenance"}, @@ -296,6 +330,21 @@ public class HelpCommand extends BaseCommand implements TabCompleter { return players; } + Player completionPlayer = sender instanceof Player senderPlayer ? senderPlayer : null; + if ((subCmd.equals("tpa") || subCmd.equals("tpahere")) + && completionPlayer != null + && sender.hasPermission("essentialsc.command." + subCmd)) { + return completeOnlinePlayers(completionPlayer, args[1], false); + } + + if ((subCmd.equals("tpaccept") || subCmd.equals("tpyes") || subCmd.equals("tpdeny") + || subCmd.equals("tpdecline") || subCmd.equals("tpno")) + && completionPlayer != null + && (sender.hasPermission("essentialsc.command.tpaccept") || sender.hasPermission("essentialsc.command.tpdeny"))) { + TeleportRequestManager manager = plugin.getTeleportRequestManager(); + return manager == null ? List.of() : manager.getIncomingRequesterNames(completionPlayer, args[1]); + } + if ((subCmd.equals("nightvision") || subCmd.equals("nv")) && sender.hasPermission("essentialsc.command.nightvision")) { return completeToggleArgs(args[1]); } @@ -335,6 +384,18 @@ public class HelpCommand extends BaseCommand implements TabCompleter { return completions; } + private List completeOnlinePlayers(Player sender, String partialInput, boolean includeSelf) { + List completions = new ArrayList<>(); + String partial = partialInput.toLowerCase(); + for (Player player : Bukkit.getOnlinePlayers()) { + if ((includeSelf || !player.getUniqueId().equals(sender.getUniqueId())) + && player.getName().toLowerCase().startsWith(partial)) { + completions.add(player.getName()); + } + } + return completions; + } + private List completeMaintenanceArgs(String partialInput) { List completions = new ArrayList<>(); String partial = partialInput.toLowerCase(); diff --git a/src/main/java/cn/infstar/essentialsC/commands/TpAcceptCommand.java b/src/main/java/cn/infstar/essentialsC/commands/TpAcceptCommand.java new file mode 100644 index 0000000..376a96f --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/commands/TpAcceptCommand.java @@ -0,0 +1,87 @@ +package cn.infstar.essentialsC.commands; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class TpAcceptCommand extends BaseCommand implements TabCompleter { + + public TpAcceptCommand() { + super("essentialsc.command.tpaccept"); + } + + @Override + protected boolean execute(Player player, String[] args) { + if (args.length > 1) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.usage-tpaccept")); + return true; + } + + TeleportRequestManager manager = plugin.getTeleportRequestManager(); + if (manager == null) { + player.sendMessage(getLang().getPrefixedString("messages.module-disabled")); + return true; + } + if (manager.isIgnoringRequests(player)) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.ignoring-requests")); + return true; + } + + Optional request = manager.findIncoming( + player, + args.length == 0 ? null : args[0] + ); + if (request.isEmpty()) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.no-request")); + return true; + } + + TeleportRequestManager.TeleportRequest accepted = request.get(); + Map placeholders = manager.placeholders(accepted); + TeleportRequestManager.TeleportResult result = manager.accept(player, accepted); + if (result.status() == TeleportRequestManager.TeleportResult.Status.EXPIRED) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.expired", placeholders)); + return true; + } + if (result.status() == TeleportRequestManager.TeleportResult.Status.PLAYER_OFFLINE) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.player-offline", placeholders)); + return true; + } + if (result.status() == TeleportRequestManager.TeleportResult.Status.ALREADY_WARMING_UP) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.already-warming-up", placeholders)); + return true; + } + if (result.status() == TeleportRequestManager.TeleportResult.Status.ON_COOLDOWN) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.accept-cooldown", + Map.of("seconds", String.valueOf(result.cooldownSeconds())))); + return true; + } + + player.sendMessage(getLang().getPrefixedString("tpa.messages.accepted-target", placeholders)); + Player requester = Bukkit.getPlayer(accepted.requesterId()); + if (requester != null && requester.isOnline()) { + requester.sendMessage(getLang().getPrefixedString("tpa.messages.accepted-sender", placeholders)); + } + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { + if (args.length != 1 || !(sender instanceof Player player)) { + return List.of(); + } + + TeleportRequestManager manager = plugin.getTeleportRequestManager(); + if (manager == null) { + return List.of(); + } + return manager.getIncomingRequesterNames(player, args[0]); + } +} diff --git a/src/main/java/cn/infstar/essentialsC/commands/TpDenyCommand.java b/src/main/java/cn/infstar/essentialsC/commands/TpDenyCommand.java new file mode 100644 index 0000000..af3947d --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/commands/TpDenyCommand.java @@ -0,0 +1,74 @@ +package cn.infstar.essentialsC.commands; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class TpDenyCommand extends BaseCommand implements TabCompleter { + + public TpDenyCommand() { + super("essentialsc.command.tpdeny"); + } + + @Override + protected boolean execute(Player player, String[] args) { + if (args.length > 1) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.usage-tpdeny")); + return true; + } + + TeleportRequestManager manager = plugin.getTeleportRequestManager(); + if (manager == null) { + player.sendMessage(getLang().getPrefixedString("messages.module-disabled")); + return true; + } + if (manager.isIgnoringRequests(player)) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.ignoring-requests")); + return true; + } + + Optional request = manager.findIncoming( + player, + args.length == 0 ? null : args[0] + ); + if (request.isEmpty()) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.no-request")); + return true; + } + + TeleportRequestManager.TeleportRequest denied = request.get(); + TeleportRequestManager.TeleportResult result = manager.deny(player, denied); + if (result.status() == TeleportRequestManager.TeleportResult.Status.EXPIRED) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.expired", manager.placeholders(denied))); + return true; + } + + Map placeholders = manager.placeholders(denied); + player.sendMessage(getLang().getPrefixedString("tpa.messages.denied-target", placeholders)); + Player requester = Bukkit.getPlayer(denied.requesterId()); + if (requester != null && requester.isOnline()) { + requester.sendMessage(getLang().getPrefixedString("tpa.messages.denied-sender", placeholders)); + } + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { + if (args.length != 1 || !(sender instanceof Player player)) { + return List.of(); + } + + TeleportRequestManager manager = plugin.getTeleportRequestManager(); + if (manager == null) { + return List.of(); + } + return manager.getIncomingRequesterNames(player, args[0]); + } +} diff --git a/src/main/java/cn/infstar/essentialsC/commands/TpIgnoreCommand.java b/src/main/java/cn/infstar/essentialsC/commands/TpIgnoreCommand.java new file mode 100644 index 0000000..9011728 --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/commands/TpIgnoreCommand.java @@ -0,0 +1,26 @@ +package cn.infstar.essentialsC.commands; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager; +import org.bukkit.entity.Player; + +public final class TpIgnoreCommand extends BaseCommand { + + public TpIgnoreCommand() { + super("essentialsc.command.tpignore"); + } + + @Override + protected boolean execute(Player player, String[] args) { + TeleportRequestManager manager = plugin.getTeleportRequestManager(); + if (manager == null) { + player.sendMessage(getLang().getPrefixedString("messages.module-disabled")); + return true; + } + + boolean ignoring = manager.toggleIgnoringRequests(player); + player.sendMessage(getLang().getPrefixedString(ignoring + ? "tpa.messages.ignore-enabled" + : "tpa.messages.ignore-disabled")); + return true; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/commands/TpaAllCommand.java b/src/main/java/cn/infstar/essentialsC/commands/TpaAllCommand.java new file mode 100644 index 0000000..688f10c --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/commands/TpaAllCommand.java @@ -0,0 +1,45 @@ +package cn.infstar.essentialsC.commands; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager; +import org.bukkit.entity.Player; + +import java.util.Map; + +public final class TpaAllCommand extends BaseCommand { + + public TpaAllCommand() { + super("essentialsc.command.tpaall"); + } + + @Override + protected boolean execute(Player player, String[] args) { + if (args.length != 0) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.usage-tpaall")); + return true; + } + + TeleportRequestManager manager = plugin.getTeleportRequestManager(); + if (manager == null) { + player.sendMessage(getLang().getPrefixedString("messages.module-disabled")); + return true; + } + if (manager.isIgnoringRequests(player)) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.ignoring-requests")); + return true; + } + + int sent = manager.sendTeleportAllRequest(player, target -> !VanishCommand.isVanished(target)); + if (sent < 0) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.send-cooldown", + Map.of("seconds", String.valueOf(manager.getSendCooldownSeconds(player))))); + return true; + } + if (sent <= 0) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.tpaall-no-targets")); + return true; + } + + player.sendMessage(getLang().getPrefixedString("tpa.messages.tpaall-sent")); + return true; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/commands/TpaCommand.java b/src/main/java/cn/infstar/essentialsC/commands/TpaCommand.java new file mode 100644 index 0000000..356701e --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/commands/TpaCommand.java @@ -0,0 +1,123 @@ +package cn.infstar.essentialsC.commands; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class TpaCommand extends BaseCommand implements TabCompleter { + + public TpaCommand() { + super("essentialsc.command.tpa"); + } + + @Override + protected boolean execute(Player player, String[] args) { + return sendRequest(player, args, TeleportRequestManager.TeleportRequest.Type.TPA); + } + + protected boolean sendRequest(Player player, String[] args, TeleportRequestManager.TeleportRequest.Type type) { + if (args.length != 1) { + player.sendMessage(getLang().getPrefixedString(type == TeleportRequestManager.TeleportRequest.Type.TPA + ? "tpa.messages.usage-tpa" + : "tpa.messages.usage-tpahere")); + return true; + } + + TeleportRequestManager manager = plugin.getTeleportRequestManager(); + if (manager == null) { + player.sendMessage(getLang().getPrefixedString("messages.module-disabled")); + return true; + } + if (manager.isIgnoringRequests(player)) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.ignoring-requests")); + return true; + } + + Player target = Bukkit.getPlayerExact(args[0]); + if (target == null || !target.isOnline() || VanishCommand.isVanished(target)) { + player.sendMessage(getLang().getPrefixedString("messages.player-not-found", Map.of("player", args[0]))); + return true; + } + + if (target.getUniqueId().equals(player.getUniqueId())) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.self")); + return true; + } + + TeleportRequestManager.CreateRequestResult createdRequest = manager.createRequest(player, target, type); + if (createdRequest.status() == TeleportRequestManager.CreateRequestStatus.DUPLICATE) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.duplicate-request", Map.of("target", target.getName()))); + return true; + } + if (createdRequest.status() == TeleportRequestManager.CreateRequestStatus.ON_COOLDOWN) { + player.sendMessage(getLang().getPrefixedString("tpa.messages.send-cooldown", + Map.of("seconds", String.valueOf(createdRequest.cooldownSeconds())))); + return true; + } + + TeleportRequestManager.TeleportRequest request = createdRequest.request(); + Map placeholders = manager.placeholders(request); + player.sendMessage(getLang().getPrefixedString(type == TeleportRequestManager.TeleportRequest.Type.TPA + ? "tpa.messages.sent-tpa" + : "tpa.messages.sent-tpahere", placeholders)); + if (createdRequest.status() == TeleportRequestManager.CreateRequestStatus.IGNORED) { + return true; + } + target.sendMessage(getLang().getPrefixedString(type == TeleportRequestManager.TeleportRequest.Type.TPA + ? "tpa.messages.received-tpa" + : "tpa.messages.received-tpahere", placeholders)); + manager.playRequestReceivedSound(target); + sendResponseHint(target, request, placeholders); + return true; + } + + private void sendResponseHint(Player target, TeleportRequestManager.TeleportRequest request, Map placeholders) { + String requesterName = request.requesterName(); + String acceptCommand = "/tpaccept " + requesterName; + String denyCommand = "/tpdeny " + requesterName; + + Component hint = LegacyComponentSerializer.legacySection() + .deserialize(getLang().getPrefixedString("tpa.messages.response-hint", placeholders)); + Component accept = LegacyComponentSerializer.legacySection() + .deserialize(getLang().getString("tpa.messages.accept-button")) + .clickEvent(ClickEvent.runCommand(acceptCommand)) + .hoverEvent(HoverEvent.showText(Component.text(acceptCommand, NamedTextColor.GREEN))); + Component deny = LegacyComponentSerializer.legacySection() + .deserialize(getLang().getString("tpa.messages.deny-button")) + .clickEvent(ClickEvent.runCommand(denyCommand)) + .hoverEvent(HoverEvent.showText(Component.text(denyCommand, NamedTextColor.RED))); + + target.sendMessage(hint.append(Component.space()).append(accept).append(Component.space()).append(deny)); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { + if (args.length != 1 || !(sender instanceof Player player)) { + return List.of(); + } + + String partial = args[0].toLowerCase(); + List completions = new ArrayList<>(); + for (Player onlinePlayer : Bukkit.getOnlinePlayers()) { + if (!onlinePlayer.getUniqueId().equals(player.getUniqueId()) + && !VanishCommand.isVanished(onlinePlayer) + && onlinePlayer.getName().toLowerCase().startsWith(partial)) { + completions.add(onlinePlayer.getName()); + } + } + return completions; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/commands/TpaHereCommand.java b/src/main/java/cn/infstar/essentialsC/commands/TpaHereCommand.java new file mode 100644 index 0000000..f1e51b5 --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/commands/TpaHereCommand.java @@ -0,0 +1,17 @@ +package cn.infstar.essentialsC.commands; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager; +import org.bukkit.entity.Player; + +public final class TpaHereCommand extends TpaCommand { + + public TpaHereCommand() { + super(); + this.permission = "essentialsc.command.tpahere"; + } + + @Override + protected boolean execute(Player player, String[] args) { + return sendRequest(player, args, TeleportRequestManager.TeleportRequest.Type.TPAHERE); + } +} diff --git a/src/main/java/cn/infstar/essentialsC/listeners/JeiRecipeSyncListener.java b/src/main/java/cn/infstar/essentialsC/listeners/JeiRecipeSyncListener.java index 213926a..8526434 100644 --- a/src/main/java/cn/infstar/essentialsC/listeners/JeiRecipeSyncListener.java +++ b/src/main/java/cn/infstar/essentialsC/listeners/JeiRecipeSyncListener.java @@ -1,28 +1,15 @@ package cn.infstar.essentialsC.listeners; import cn.infstar.essentialsC.EssentialsC; -import io.netty.buffer.Unpooled; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket; -import net.minecraft.network.protocol.common.custom.DiscardedPayload; -import net.minecraft.resources.Identifier; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.item.crafting.RecipeHolder; -import net.minecraft.world.item.crafting.RecipeMap; -import net.minecraft.world.item.crafting.RecipeSerializer; +import cn.infstar.essentialsC.compat.jei.JeiRecipeSyncAdapter; import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.craftbukkit.entity.CraftPlayer; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; import java.util.Locale; +import java.util.logging.Level; /** * 为 Fabric / NeoForge 客户端补发配方同步数据,修复 1.21.2+ 的 JEI 配方显示问题。 @@ -34,6 +21,7 @@ public class JeiRecipeSyncListener implements Listener { private final boolean debug; private final boolean sendPlayerMessage; private final int brandCheckDelayTicks; + private final JeiRecipeSyncAdapter adapter; public JeiRecipeSyncListener(EssentialsC plugin) { this.plugin = plugin; @@ -47,6 +35,7 @@ public class JeiRecipeSyncListener implements Listener { this.debug = config.getBoolean("jei-sync.debug", config.getBoolean("debug", false)); this.sendPlayerMessage = config.getBoolean("jei-sync.send-player-message", true); this.brandCheckDelayTicks = Math.max(0, config.getInt("jei-sync.brand-check-delay-ticks", 20)); + this.adapter = loadAdapter(); } @EventHandler @@ -54,6 +43,12 @@ public class JeiRecipeSyncListener implements Listener { if (!enabled) { return; } + if (adapter == null) { + if (debug) { + plugin.getLogger().warning("当前服务端版本没有可用的 JEI 配方同步适配器。"); + } + return; + } Player player = event.getPlayer(); plugin.getServer().getScheduler().runTaskLater(plugin, () -> detectAndSync(player), brandCheckDelayTicks); @@ -123,227 +118,47 @@ public class JeiRecipeSyncListener implements Listener { player.sendMessage(component); } - @SuppressWarnings({"unchecked", "deprecation"}) private void sendFabricRecipeSync(Player player) { try { - ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); - MinecraftServer server = serverPlayer.level().getServer(); - if (server == null) { - if (debug) { - plugin.getLogger().warning("服务端实例为 null"); - } - return; - } - - RecipeMap recipeMap = server.getRecipeManager().recipes; - if (debug) { - plugin.getLogger().info("开始构建 Fabric 配方数据"); - } - - var list = new ArrayList(); - var seen = new HashSet>(); - - for (RecipeSerializer serializer : BuiltInRegistries.RECIPE_SERIALIZER) { - if (!seen.add(serializer)) { - continue; - } - - List> recipes = new ArrayList<>(); - for (RecipeHolder holder : recipeMap.values()) { - if (holder.value().getSerializer() == serializer) { - recipes.add(holder); - } - } - - if (!recipes.isEmpty()) { - RecipeSerializer entrySerializer = recipes.get(0).value().getSerializer(); - list.add(new FabricRecipeEntry(entrySerializer, recipes)); - } - } - - var payload = new FabricRecipeSyncPayload(list); - if (debug) { - plugin.getLogger().info("Fabric 配方条目数: " + list.size()); - } - - RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), server.registryAccess()); - getFabricCodec().encode(buffer, payload); - - byte[] bytes = new byte[buffer.writerIndex()]; - buffer.getBytes(0, bytes); - - Identifier id = Identifier.fromNamespaceAndPath("fabric", "recipe_sync"); - DiscardedPayload discardedPayload = new DiscardedPayload(id, bytes); - serverPlayer.connection.send(new ClientboundCustomPayloadPacket(discardedPayload)); - - if (debug) { - plugin.getLogger().info("已发送 Fabric 配方同步 [" + id + "], 大小: " + bytes.length + " bytes"); - } + adapter.sendFabricRecipeSync(player, plugin.getLogger(), debug); } catch (Exception e) { plugin.getLogger().warning("发送 Fabric 配方同步失败: " + e.getMessage()); - e.printStackTrace(); + if (debug) { + plugin.getLogger().log(Level.WARNING, "Fabric 配方同步异常详情", e); + } } } - @SuppressWarnings({"unchecked", "deprecation"}) - private net.minecraft.network.codec.StreamCodec getFabricCodec() { - return FabricRecipeEntry.CODEC.apply(net.minecraft.network.codec.ByteBufCodecs.list()) - .map(FabricRecipeSyncPayload::new, FabricRecipeSyncPayload::entries); - } - - @SuppressWarnings({"unchecked", "deprecation"}) private void sendNeoForgeRecipeSync(Player player) { try { - ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); - MinecraftServer server = serverPlayer.level().getServer(); - if (server == null) { - if (debug) { - plugin.getLogger().warning("服务端实例为 null"); - } - return; - } - - RecipeMap recipeMap = server.getRecipeManager().recipes; - if (debug) { - plugin.getLogger().info("开始构建 NeoForge 配方数据"); - } - - java.util.List> allRecipeTypes = - BuiltInRegistries.RECIPE_TYPE.stream().toList(); - if (debug) { - plugin.getLogger().info("NeoForge 配方类型数: " + allRecipeTypes.size()); - } - - var payload = createNeoForgePayload(allRecipeTypes, recipeMap); - RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), server.registryAccess()); - getNeoForgeStreamCodec().encode(buffer, payload); - - byte[] bytes = new byte[buffer.writerIndex()]; - buffer.getBytes(0, bytes); - - Identifier id = Identifier.fromNamespaceAndPath("neoforge", "recipe_content"); - DiscardedPayload discardedPayload = new DiscardedPayload(id, bytes); - serverPlayer.connection.send(new ClientboundCustomPayloadPacket(discardedPayload)); - serverPlayer.connection.send(new net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket( - net.minecraft.tags.TagNetworkSerialization.serializeTagsToNetwork(server.registries()) - )); - - if (debug) { - plugin.getLogger().info("已发送 NeoForge 配方同步 [" + id + "], 大小: " + bytes.length + " bytes"); - } + adapter.sendNeoForgeRecipeSync(player, plugin.getLogger(), debug); } catch (Exception e) { plugin.getLogger().warning("发送 NeoForge 配方同步失败: " + e.getMessage()); - e.printStackTrace(); - } - } - - private NeoForgeRecipeSyncPayload createNeoForgePayload( - java.util.List> recipeTypes, - RecipeMap recipeMap - ) { - var recipeTypeSet = new java.util.HashSet<>(recipeTypes); - if (recipeTypeSet.isEmpty()) { - return new NeoForgeRecipeSyncPayload(recipeTypeSet, java.util.List.of()); - } - - var recipeSubset = recipeMap.values().stream() - .filter(h -> recipeTypeSet.contains(h.value().getType())) - .toList(); - return new NeoForgeRecipeSyncPayload(recipeTypeSet, recipeSubset); - } - - @SuppressWarnings({"unchecked", "deprecation"}) - private net.minecraft.network.codec.StreamCodec getNeoForgeStreamCodec() { - return net.minecraft.network.codec.StreamCodec.composite( - net.minecraft.network.codec.ByteBufCodecs.registry(net.minecraft.core.registries.Registries.RECIPE_TYPE) - .apply(net.minecraft.network.codec.ByteBufCodecs.collection(java.util.HashSet::new)), - NeoForgeRecipeSyncPayload::recipeTypes, - RecipeHolder.STREAM_CODEC.apply(net.minecraft.network.codec.ByteBufCodecs.list()), - NeoForgeRecipeSyncPayload::recipes, - NeoForgeRecipeSyncPayload::new - ); - } - - @SuppressWarnings("deprecation") - private static class FabricRecipeEntry { - // 这里保留 Object,是为了避免直接绑定易变的 NMS 泛型签名。 - final Object serializer; - final List> recipes; - - FabricRecipeEntry(Object serializer, List> recipes) { - this.serializer = serializer; - this.recipes = recipes; - } - - static final net.minecraft.network.codec.StreamCodec CODEC = - net.minecraft.network.codec.StreamCodec.ofMember( - FabricRecipeEntry::write, - FabricRecipeEntry::read - ); - - @SuppressWarnings("unchecked") - private static FabricRecipeEntry read(RegistryFriendlyByteBuf buf) { - Identifier recipeSerializerId = buf.readIdentifier(); - RecipeSerializer recipeSerializer = BuiltInRegistries.RECIPE_SERIALIZER.getValue(recipeSerializerId); - - if (recipeSerializer == null) { - throw new RuntimeException("Tried syncing unsupported packet serializer '" + recipeSerializerId + "'!"); + if (debug) { + plugin.getLogger().log(Level.WARNING, "NeoForge 配方同步异常详情", e); } + } + } - int count = buf.readVarInt(); - var list = new ArrayList>(); - for (int i = 0; i < count; i++) { - net.minecraft.resources.ResourceKey> id = - buf.readResourceKey(net.minecraft.core.registries.Registries.RECIPE); - - try { - var streamCodecMethod = recipeSerializer.getClass().getMethod("streamCodec"); - var streamCodec = streamCodecMethod.invoke(recipeSerializer); - net.minecraft.world.item.crafting.Recipe recipe = - ((net.minecraft.network.codec.StreamCodec>) streamCodec) - .decode(buf); - list.add(new RecipeHolder<>(id, recipe)); - } catch (Exception e) { - throw new RuntimeException("Failed to decode recipe: " + e.getMessage(), e); + private JeiRecipeSyncAdapter loadAdapter() { + String versionKey = plugin.getServer().getBukkitVersion().split("-")[0] + .replace('.', '_'); + String className = "cn.infstar.essentialsC.v" + versionKey + ".jei.JeiRecipeSyncAdapterImpl"; + try { + Class adapterClass = Class.forName(className); + Object instance = adapterClass.getDeclaredConstructor().newInstance(); + if (instance instanceof JeiRecipeSyncAdapter jeiRecipeSyncAdapter) { + if (debug) { + plugin.getLogger().info("已加载 JEI 配方同步适配器: " + className); } + return jeiRecipeSyncAdapter; } - - return new FabricRecipeEntry(recipeSerializer, list); - } - - private void write(RegistryFriendlyByteBuf buf) { - try { - var getKeyMethod = BuiltInRegistries.RECIPE_SERIALIZER.getClass().getMethod("getKey", Object.class); - Identifier identifier = (Identifier) getKeyMethod.invoke(BuiltInRegistries.RECIPE_SERIALIZER, this.serializer); - buf.writeIdentifier(identifier); - } catch (Exception e) { - throw new RuntimeException("Failed to get serializer key: " + e.getMessage(), e); - } - - buf.writeVarInt(this.recipes.size()); - - try { - var streamCodecMethod = this.serializer.getClass().getMethod("streamCodec"); - @SuppressWarnings("unchecked") - var codec = (net.minecraft.network.codec.StreamCodec>) - streamCodecMethod.invoke(this.serializer); - - for (RecipeHolder recipe : this.recipes) { - buf.writeResourceKey(recipe.id()); - codec.encode(buf, recipe.value()); - } - } catch (Exception e) { - throw new RuntimeException("Failed to encode recipe: " + e.getMessage(), e); + plugin.getLogger().warning("JEI 配方同步适配器类型无效: " + className); + } catch (ReflectiveOperationException | LinkageError e) { + if (debug) { + plugin.getLogger().warning("加载 JEI 配方同步适配器失败: " + className + " (" + e.getMessage() + ")"); } } - } - - private record FabricRecipeSyncPayload(List entries) { - } - - private record NeoForgeRecipeSyncPayload( - java.util.Set> recipeTypes, - java.util.List> recipes - ) { + return null; } } diff --git a/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestManager.java b/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestManager.java new file mode 100644 index 0000000..1664e8b --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestManager.java @@ -0,0 +1,825 @@ +package cn.infstar.essentialsC.teleport; + +import cn.infstar.essentialsC.EssentialsC; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitTask; + +import java.io.File; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; + +public final class TeleportRequestManager implements Listener { + + public static final String BYPASS_WARMUP_PERMISSION = "essentialsc.tpa.bypass-warmup"; + public static final String BYPASS_COOLDOWN_PERMISSION = "essentialsc.tpa.bypass-cooldown"; + + private final EssentialsC plugin; + private final Map> requests = new HashMap<>(); + private final Map warmups = new HashMap<>(); + private final Map sendCooldowns = new HashMap<>(); + private final Map acceptCooldowns = new HashMap<>(); + private final Set ignoringRequests = new HashSet<>(); + private final File ignoreFile; + private BukkitTask cleanupTask; + + private int timeoutSeconds; + private boolean strictTpaRequests; + private boolean strictTpaHereRequests; + private int warmupSeconds; + private boolean cancelWarmupOnMove; + private boolean cancelWarmupOnDamage; + private boolean cooldownsEnabled; + private int sendCooldownSeconds; + private int acceptCooldownSeconds; + private double warmupMoveThreshold; + private String warmupDisplay; + private boolean soundsEnabled; + private String requestSound; + private String warmupSound; + private String cancelSound; + private String completeSound; + + public TeleportRequestManager(EssentialsC plugin) { + this.plugin = plugin; + this.ignoreFile = new File(plugin.getDataFolder(), "teleport-ignore.yml"); + reload(); + } + + public void reload() { + plugin.getConfig().addDefault("tpa.timeout-seconds", 60); + plugin.getConfig().addDefault("tpa.strict-tpa-requests", false); + plugin.getConfig().addDefault("tpa.strict-tpahere-requests", true); + plugin.getConfig().addDefault("tpa.warmup-seconds", 5); + plugin.getConfig().addDefault("tpa.cancel-warmup-on-move", true); + plugin.getConfig().addDefault("tpa.cancel-warmup-on-damage", true); + plugin.getConfig().addDefault("tpa.cooldowns.enabled", true); + plugin.getConfig().addDefault("tpa.cooldowns.cooldown-times.SEND_TELEPORT_REQUEST", 0); + plugin.getConfig().addDefault("tpa.cooldowns.cooldown-times.ACCEPT_TELEPORT_REQUEST", 0); + plugin.getConfig().addDefault("tpa.warmup-move-threshold", 0.1D); + plugin.getConfig().addDefault("tpa.warmup-display", "actionbar"); + plugin.getConfig().addDefault("tpa.sounds.enabled", true); + plugin.getConfig().addDefault("tpa.sounds.request-received", "entity.experience_orb.pickup"); + plugin.getConfig().addDefault("tpa.sounds.warmup", "block.note_block.banjo"); + plugin.getConfig().addDefault("tpa.sounds.cancelled", "entity.item.break"); + plugin.getConfig().addDefault("tpa.sounds.complete", "entity.enderman.teleport"); + plugin.getConfig().options().copyDefaults(true); + plugin.saveConfig(); + + timeoutSeconds = Math.max(5, plugin.getConfig().getInt("tpa.timeout-seconds", 60)); + strictTpaRequests = plugin.getConfig().getBoolean("tpa.strict-tpa-requests", false); + strictTpaHereRequests = plugin.getConfig().getBoolean("tpa.strict-tpahere-requests", true); + warmupSeconds = Math.max(0, plugin.getConfig().getInt("tpa.warmup-seconds", 5)); + cancelWarmupOnMove = plugin.getConfig().getBoolean("tpa.cancel-warmup-on-move", true); + cancelWarmupOnDamage = plugin.getConfig().getBoolean("tpa.cancel-warmup-on-damage", true); + cooldownsEnabled = plugin.getConfig().getBoolean("tpa.cooldowns.enabled", true); + sendCooldownSeconds = Math.max(0, plugin.getConfig().getInt("tpa.cooldowns.cooldown-times.SEND_TELEPORT_REQUEST", 0)); + acceptCooldownSeconds = Math.max(0, plugin.getConfig().getInt("tpa.cooldowns.cooldown-times.ACCEPT_TELEPORT_REQUEST", 0)); + warmupMoveThreshold = Math.max(0.0D, plugin.getConfig().getDouble("tpa.warmup-move-threshold", 0.1D)); + warmupDisplay = plugin.getConfig().getString("tpa.warmup-display", "actionbar").toLowerCase(); + soundsEnabled = plugin.getConfig().getBoolean("tpa.sounds.enabled", true); + requestSound = plugin.getConfig().getString("tpa.sounds.request-received", "entity.experience_orb.pickup"); + warmupSound = plugin.getConfig().getString("tpa.sounds.warmup", "block.note_block.banjo"); + cancelSound = plugin.getConfig().getString("tpa.sounds.cancelled", "entity.item.break"); + completeSound = plugin.getConfig().getString("tpa.sounds.complete", "entity.enderman.teleport"); + loadIgnoringRequests(); + startCleanupTask(); + } + + public void shutdown() { + requests.clear(); + sendCooldowns.clear(); + acceptCooldowns.clear(); + cancelAllWarmups(); + cancelCleanupTask(); + saveIgnoringRequests(); + } + + public CreateRequestResult createRequest(Player requester, Player target, TeleportRequest.Type type) { + return createRequest(requester, target, type, true); + } + + private CreateRequestResult createRequest(Player requester, Player target, TeleportRequest.Type type, boolean applyCooldown) { + OptionalIntCooldown sendCooldown = getRemainingCooldown(requester, sendCooldowns); + if (applyCooldown && sendCooldown.active()) { + return new CreateRequestResult(CreateRequestStatus.ON_COOLDOWN, null, sendCooldown.seconds()); + } + + Optional existingRequest = findIncoming(target, requester.getName()); + if (existingRequest.isPresent() + && existingRequest.get().type() == type + && !existingRequest.get().hasExpired()) { + return new CreateRequestResult(CreateRequestStatus.DUPLICATE, existingRequest.get()); + } + + TeleportRequest request = new TeleportRequest( + requester.getUniqueId(), + requester.getName(), + requester.getLocation().clone(), + target.getUniqueId(), + target.getName(), + target.getLocation().clone(), + type, + Instant.now().plusSeconds(timeoutSeconds).toEpochMilli(), + TeleportRequest.Status.PENDING + ); + + if (isIgnoringRequests(target)) { + request.setStatus(TeleportRequest.Status.IGNORED); + if (applyCooldown) { + startCooldown(requester, sendCooldowns, sendCooldownSeconds); + } + return new CreateRequestResult(CreateRequestStatus.IGNORED, request); + } + + Deque targetRequests = requests.computeIfAbsent(target.getUniqueId(), ignored -> new ArrayDeque<>()); + targetRequests.addFirst(request); + if (applyCooldown) { + startCooldown(requester, sendCooldowns, sendCooldownSeconds); + } + return new CreateRequestResult(CreateRequestStatus.SUCCESS, request, 0); + } + + public Optional findIncoming(Player target, String requesterName) { + Deque targetRequests = requests.getOrDefault(target.getUniqueId(), new ArrayDeque<>()); + if (requesterName == null) { + return targetRequests.stream().findFirst(); + } + + Optional unexpired = targetRequests.stream() + .filter(request -> request.requesterName().equalsIgnoreCase(requesterName)) + .filter(request -> !request.hasExpired()) + .findFirst(); + if (unexpired.isPresent()) { + return unexpired; + } + + return targetRequests.stream() + .filter(request -> request.requesterName().equalsIgnoreCase(requesterName)) + .findFirst(); + } + + public Optional findOutgoing(Player requester, String targetName) { + return requests.values().stream() + .flatMap(Deque::stream) + .filter(request -> request.requesterId().equals(requester.getUniqueId())) + .filter(request -> !request.hasExpired()) + .filter(request -> targetName == null || request.targetName().equalsIgnoreCase(targetName)) + .max(Comparator.comparingLong(TeleportRequest::expiresAtMillis)); + } + + public List getIncomingRequesterNames(Player target, String partial) { + String partialLower = partial == null ? "" : partial.toLowerCase(); + return requests.getOrDefault(target.getUniqueId(), new ArrayDeque<>()).stream() + .filter(request -> !request.hasExpired()) + .map(TeleportRequest::requesterName) + .filter(name -> name.toLowerCase().startsWith(partialLower)) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + + public List getOutgoingTargetNames(Player requester, String partial) { + String partialLower = partial == null ? "" : partial.toLowerCase(); + return requests.values().stream() + .flatMap(Deque::stream) + .filter(request -> request.requesterId().equals(requester.getUniqueId())) + .filter(request -> !request.hasExpired()) + .map(TeleportRequest::targetName) + .filter(name -> name.toLowerCase().startsWith(partialLower)) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + + public TeleportResult accept(Player target, TeleportRequest request) { + OptionalIntCooldown acceptCooldown = getRemainingCooldown(target, acceptCooldowns); + if (acceptCooldown.active()) { + return TeleportResult.onCooldown(acceptCooldown.seconds()); + } + + if (request.hasExpired()) { + removeIncomingByRequester(target, request.requesterName()); + return TeleportResult.EXPIRED; + } + + Player requester = Bukkit.getPlayer(request.requesterId()); + Player currentTarget = Bukkit.getPlayer(request.targetId()); + if (requester == null || currentTarget == null || !requester.isOnline() || !currentTarget.isOnline()) { + return TeleportResult.PLAYER_OFFLINE; + } + + TeleportPlan plan = createTeleportPlan(request, requester, currentTarget); + Player teleporter = plan.teleporter(); + if (warmups.containsKey(teleporter.getUniqueId())) { + return TeleportResult.ALREADY_WARMING_UP; + } + + removeIncomingByRequester(target, request.requesterName()); + request.setStatus(TeleportRequest.Status.ACCEPTED); + if (warmupSeconds <= 0 || teleporter.hasPermission(BYPASS_WARMUP_PERMISSION)) { + TeleportResult result = executeTeleport(plan, true); + if (result.status() == TeleportResult.Status.SUCCESS) { + startCooldown(target, acceptCooldowns, acceptCooldownSeconds); + } + return result; + } + + startWarmup(plan); + startCooldown(target, acceptCooldowns, acceptCooldownSeconds); + return TeleportResult.WARMING_UP; + } + + public TeleportResult deny(Player target, TeleportRequest request) { + if (!removeIncomingByRequester(target, request.requesterName())) { + return TeleportResult.EXPIRED; + } + if (request.hasExpired()) { + return TeleportResult.EXPIRED; + } + request.setStatus(TeleportRequest.Status.DECLINED); + return TeleportResult.SUCCESS; + } + + public boolean cancel(TeleportRequest request) { + Deque targetRequests = requests.get(request.targetId()); + if (targetRequests == null) { + return false; + } + + boolean removed = targetRequests.removeIf(candidate -> + candidate.requesterName().equalsIgnoreCase(request.requesterName())); + if (targetRequests.isEmpty()) { + requests.remove(request.targetId()); + } + return removed; + } + + public boolean isIgnoringRequests(Player player) { + return ignoringRequests.contains(player.getUniqueId()); + } + + public boolean toggleIgnoringRequests(Player player) { + UUID uuid = player.getUniqueId(); + boolean nowIgnoring; + if (ignoringRequests.contains(uuid)) { + ignoringRequests.remove(uuid); + nowIgnoring = false; + } else { + ignoringRequests.add(uuid); + nowIgnoring = true; + } + saveIgnoringRequests(); + return nowIgnoring; + } + + public void playRequestReceivedSound(Player player) { + playConfiguredSound(player, requestSound, 0.7F, 1.0F); + } + + public int sendTeleportAllRequest(Player requester) { + return sendTeleportAllRequest(requester, ignored -> true); + } + + public int sendTeleportAllRequest(Player requester, Predicate targetFilter) { + OptionalIntCooldown sendCooldown = getRemainingCooldown(requester, sendCooldowns); + if (sendCooldown.active()) { + return -sendCooldown.seconds(); + } + + int recipients = 0; + for (Player target : Bukkit.getOnlinePlayers()) { + if (target.getUniqueId().equals(requester.getUniqueId())) { + continue; + } + if (!targetFilter.test(target)) { + continue; + } + recipients++; + CreateRequestResult result = createRequest(requester, target, TeleportRequest.Type.TPAHERE, false); + if (result.status() != CreateRequestStatus.SUCCESS) { + continue; + } + Map placeholders = placeholders(result.request()); + target.sendMessage(plugin.getLangManager().getPrefixedString("tpa.messages.received-tpahere", placeholders)); + sendResponseHint(target, result.request(), placeholders); + playRequestReceivedSound(target); + } + if (recipients > 0) { + startCooldown(requester, sendCooldowns, sendCooldownSeconds); + } + return recipients; + } + + public int getSendCooldownSeconds(Player player) { + return getRemainingCooldown(player, sendCooldowns).seconds(); + } + + private void sendResponseHint(Player target, TeleportRequest request, Map placeholders) { + String requesterName = request.requesterName(); + String acceptCommand = "/tpaccept " + requesterName; + String denyCommand = "/tpdeny " + requesterName; + + Component hint = LegacyComponentSerializer.legacySection() + .deserialize(plugin.getLangManager().getPrefixedString("tpa.messages.response-hint", placeholders)); + Component accept = LegacyComponentSerializer.legacySection() + .deserialize(plugin.getLangManager().getString("tpa.messages.accept-button")) + .clickEvent(ClickEvent.runCommand(acceptCommand)) + .hoverEvent(HoverEvent.showText(Component.text(acceptCommand, NamedTextColor.GREEN))); + Component deny = LegacyComponentSerializer.legacySection() + .deserialize(plugin.getLangManager().getString("tpa.messages.deny-button")) + .clickEvent(ClickEvent.runCommand(denyCommand)) + .hoverEvent(HoverEvent.showText(Component.text(denyCommand, NamedTextColor.RED))); + + target.sendMessage(hint.append(Component.space()).append(accept).append(Component.space()).append(deny)); + } + + public int getTimeoutSeconds() { + return timeoutSeconds; + } + + private OptionalIntCooldown getRemainingCooldown(Player player, Map cooldowns) { + if (player.hasPermission(BYPASS_COOLDOWN_PERMISSION)) { + return OptionalIntCooldown.inactive(); + } + if (!cooldownsEnabled) { + return OptionalIntCooldown.inactive(); + } + + long expiresAt = cooldowns.getOrDefault(player.getUniqueId(), 0L); + long now = System.currentTimeMillis(); + if (expiresAt <= now) { + cooldowns.remove(player.getUniqueId()); + return OptionalIntCooldown.inactive(); + } + + int seconds = (int) Math.ceil((expiresAt - now) / 1000.0D); + return new OptionalIntCooldown(true, Math.max(1, seconds)); + } + + private void startCooldown(Player player, Map cooldowns, int seconds) { + if (!cooldownsEnabled || seconds <= 0 || player.hasPermission(BYPASS_COOLDOWN_PERMISSION)) { + return; + } + cooldowns.put(player.getUniqueId(), System.currentTimeMillis() + seconds * 1000L); + } + + private TeleportPlan createTeleportPlan(TeleportRequest request, Player requester, Player currentTarget) { + if (request.type() == TeleportRequest.Type.TPA) { + return new TeleportPlan( + requester, + strictTpaRequests ? currentTarget.getLocation().clone() : null, + strictTpaRequests ? null : currentTarget.getUniqueId() + ); + } + return new TeleportPlan( + currentTarget, + strictTpaHereRequests ? request.requesterLocation().clone() : null, + strictTpaHereRequests ? null : requester.getUniqueId() + ); + } + + private void startWarmup(TeleportPlan plan) { + Player teleporter = plan.teleporter(); + UUID uuid = teleporter.getUniqueId(); + PendingTeleport pendingTeleport = new PendingTeleport( + plan, + teleporter.getLocation().clone(), + warmupSeconds + ); + BukkitTask task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tickWarmup(uuid), 0L, 20L); + pendingTeleport.setTask(task); + warmups.put(uuid, pendingTeleport); + teleporter.sendMessage(plugin.getLangManager().getPrefixedString("tpa.messages.warmup-start", + Map.of("seconds", String.valueOf(warmupSeconds)))); + } + + private void tickWarmup(UUID teleporterId) { + PendingTeleport pendingTeleport = warmups.get(teleporterId); + if (pendingTeleport == null) { + return; + } + + Player teleporter = Bukkit.getPlayer(teleporterId); + if (teleporter == null || !teleporter.isOnline()) { + cancelWarmup(teleporterId, null, false); + return; + } + + if (pendingTeleport.remainingSeconds() <= 0) { + cancelWarmup(teleporterId, null, false); + TeleportResult result = executeTeleport(pendingTeleport.plan(), true); + if (result.status() == TeleportResult.Status.PLAYER_OFFLINE) { + teleporter.sendMessage(plugin.getLangManager().getPrefixedString("tpa.messages.player-offline")); + } + return; + } + + sendWarmupStatus(teleporter, pendingTeleport.remainingSeconds()); + playConfiguredSound(teleporter, warmupSound, 0.5F, 1.0F); + pendingTeleport.decrementRemainingSeconds(); + } + + private TeleportResult executeTeleport(TeleportPlan plan, boolean notifyCompletion) { + Player teleporter = plan.teleporter(); + if (!teleporter.isOnline()) { + return TeleportResult.PLAYER_OFFLINE; + } + + Location destination = plan.fixedDestination(); + if (destination == null && plan.dynamicTargetId() != null) { + Player dynamicTarget = Bukkit.getPlayer(plan.dynamicTargetId()); + if (dynamicTarget == null || !dynamicTarget.isOnline()) { + return TeleportResult.PLAYER_OFFLINE; + } + destination = dynamicTarget.getLocation().clone(); + } + + if (destination == null) { + return TeleportResult.PLAYER_OFFLINE; + } + + teleporter.teleportAsync(destination).thenAccept(success -> { + if (!success || !notifyCompletion) { + return; + } + Bukkit.getScheduler().runTask(plugin, () -> { + if (teleporter.isOnline()) { + teleporter.sendMessage(plugin.getLangManager().getPrefixedString("tpa.messages.teleport-complete")); + playConfiguredSound(teleporter, completeSound, 0.7F, 1.0F); + } + }); + }); + return TeleportResult.SUCCESS; + } + + private void sendWarmupStatus(Player player, int seconds) { + String message = plugin.getLangManager().getString("tpa.messages.warmup-status", + Map.of("seconds", String.valueOf(seconds))); + if ("actionbar".equalsIgnoreCase(warmupDisplay)) { + player.sendActionBar(LegacyComponentSerializer.legacySection().deserialize(message)); + return; + } + if (!"none".equalsIgnoreCase(warmupDisplay)) { + player.sendMessage(message); + } + } + + private void cancelWarmup(UUID teleporterId, String messagePath, boolean playSound) { + PendingTeleport pendingTeleport = warmups.remove(teleporterId); + if (pendingTeleport == null) { + return; + } + if (pendingTeleport.task() != null) { + pendingTeleport.task().cancel(); + } + + Player player = Bukkit.getPlayer(teleporterId); + if (player != null && player.isOnline() && messagePath != null) { + player.sendMessage(plugin.getLangManager().getPrefixedString(messagePath)); + if (playSound) { + playConfiguredSound(player, cancelSound, 0.7F, 1.0F); + } + } + } + + private void cancelAllWarmups() { + List warmingPlayers = new ArrayList<>(warmups.keySet()); + for (UUID warmingPlayer : warmingPlayers) { + cancelWarmup(warmingPlayer, null, false); + } + } + + private void playConfiguredSound(Player player, String sound, float volume, float pitch) { + if (!soundsEnabled || sound == null || sound.isBlank()) { + return; + } + try { + player.playSound(player.getLocation(), sound, volume, pitch); + } catch (IllegalArgumentException ignored) { + plugin.getLogger().warning("无效的 TPA 音效配置: " + sound); + } + } + + private boolean removeIncomingByRequester(Player target, String requesterName) { + Deque targetRequests = requests.get(target.getUniqueId()); + if (targetRequests == null) { + return false; + } + + boolean removed = targetRequests.removeIf(request -> + request.requesterName().equalsIgnoreCase(requesterName)); + if (targetRequests.isEmpty()) { + requests.remove(target.getUniqueId()); + } + return removed; + } + + public void purgeExpired() { + List emptyQueues = new ArrayList<>(); + for (Map.Entry> entry : requests.entrySet()) { + entry.getValue().removeIf(TeleportRequest::hasExpired); + if (entry.getValue().isEmpty()) { + emptyQueues.add(entry.getKey()); + } + } + emptyQueues.forEach(requests::remove); + } + + public Map placeholders(TeleportRequest request) { + Map placeholders = new HashMap<>(); + placeholders.put("requester", request.requesterName()); + placeholders.put("target", request.targetName()); + placeholders.put("seconds", String.valueOf(timeoutSeconds)); + placeholders.put("type", request.type().name().toLowerCase()); + return placeholders; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerMove(PlayerMoveEvent event) { + if (!cancelWarmupOnMove || !warmups.containsKey(event.getPlayer().getUniqueId())) { + return; + } + + PendingTeleport pendingTeleport = warmups.get(event.getPlayer().getUniqueId()); + Location start = pendingTeleport.startLocation(); + Location to = event.getTo(); + if (to == null || !sameWorld(start, to)) { + cancelWarmup(event.getPlayer().getUniqueId(), "tpa.messages.warmup-cancelled-move", true); + return; + } + + double distance = Math.abs(start.getX() - to.getX()) + + Math.abs(start.getY() - to.getY()) + + Math.abs(start.getZ() - to.getZ()); + if (distance > warmupMoveThreshold) { + cancelWarmup(event.getPlayer().getUniqueId(), "tpa.messages.warmup-cancelled-move", true); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onEntityDamage(EntityDamageEvent event) { + if (cancelWarmupOnDamage && event.getEntity() instanceof Player player && warmups.containsKey(player.getUniqueId())) { + cancelWarmup(player.getUniqueId(), "tpa.messages.warmup-cancelled-damage", true); + } + } + + @EventHandler + private void onPlayerJoin(PlayerJoinEvent event) { + if (!isIgnoringRequests(event.getPlayer())) { + return; + } + + event.getPlayer().sendMessage(plugin.getLangManager().getPrefixedString("tpa.messages.ignore-notification")); + } + + @EventHandler + private void onPlayerQuit(PlayerQuitEvent event) { + cancelWarmup(event.getPlayer().getUniqueId(), null, false); + } + + private void startCleanupTask() { + if (cleanupTask != null) { + return; + } + cleanupTask = Bukkit.getScheduler().runTaskTimer(plugin, this::purgeExpired, 20L * 60L, 20L * 60L); + } + + private void cancelCleanupTask() { + if (cleanupTask == null) { + return; + } + cleanupTask.cancel(); + cleanupTask = null; + } + + private boolean sameWorld(Location first, Location second) { + return first.getWorld() != null + && second.getWorld() != null + && first.getWorld().getUID().equals(second.getWorld().getUID()); + } + + private void loadIgnoringRequests() { + ignoringRequests.clear(); + if (!ignoreFile.exists()) { + return; + } + + FileConfiguration ignoreConfig = YamlConfiguration.loadConfiguration(ignoreFile); + if (!ignoreConfig.isConfigurationSection("ignored")) { + return; + } + + for (String key : ignoreConfig.getConfigurationSection("ignored").getKeys(false)) { + if (!ignoreConfig.getBoolean("ignored." + key, false)) { + continue; + } + try { + ignoringRequests.add(UUID.fromString(key)); + } catch (IllegalArgumentException ignored) { + plugin.getLogger().warning("忽略无效的 TPA 忽略记录 UUID: " + key); + } + } + } + + private void saveIgnoringRequests() { + FileConfiguration ignoreConfig = new YamlConfiguration(); + for (UUID uuid : ignoringRequests) { + ignoreConfig.set("ignored." + uuid, true); + } + try { + ignoreConfig.save(ignoreFile); + } catch (IOException e) { + plugin.getLogger().warning("保存 teleport-ignore.yml 失败: " + e.getMessage()); + } + } + + public enum CreateRequestStatus { + SUCCESS, + DUPLICATE, + IGNORED, + ON_COOLDOWN + } + + public record TeleportResult(Status status, int cooldownSeconds) { + public static final TeleportResult SUCCESS = new TeleportResult(Status.SUCCESS, 0); + public static final TeleportResult WARMING_UP = new TeleportResult(Status.WARMING_UP, 0); + public static final TeleportResult EXPIRED = new TeleportResult(Status.EXPIRED, 0); + public static final TeleportResult PLAYER_OFFLINE = new TeleportResult(Status.PLAYER_OFFLINE, 0); + public static final TeleportResult ALREADY_WARMING_UP = new TeleportResult(Status.ALREADY_WARMING_UP, 0); + + public static TeleportResult onCooldown(int seconds) { + return new TeleportResult(Status.ON_COOLDOWN, seconds); + } + + public enum Status { + SUCCESS, + WARMING_UP, + EXPIRED, + PLAYER_OFFLINE, + ALREADY_WARMING_UP, + ON_COOLDOWN + } + } + + public record CreateRequestResult(CreateRequestStatus status, TeleportRequest request, int cooldownSeconds) { + public CreateRequestResult(CreateRequestStatus status, TeleportRequest request) { + this(status, request, 0); + } + } + + public static final class TeleportRequest { + private final UUID requesterId; + private final String requesterName; + private final Location requesterLocation; + private final UUID targetId; + private final String targetName; + private final Location targetLocation; + private final Type type; + private final long expiresAtMillis; + private Status status; + + public TeleportRequest(UUID requesterId, String requesterName, Location requesterLocation, + UUID targetId, String targetName, Location targetLocation, + Type type, long expiresAtMillis, Status status) { + this.requesterId = requesterId; + this.requesterName = requesterName; + this.requesterLocation = requesterLocation; + this.targetId = targetId; + this.targetName = targetName; + this.targetLocation = targetLocation; + this.type = type; + this.expiresAtMillis = expiresAtMillis; + this.status = status; + } + + public UUID requesterId() { + return requesterId; + } + + public String requesterName() { + return requesterName; + } + + public Location requesterLocation() { + return requesterLocation; + } + + public UUID targetId() { + return targetId; + } + + public String targetName() { + return targetName; + } + + public Location targetLocation() { + return targetLocation; + } + + public Type type() { + return type; + } + + public long expiresAtMillis() { + return expiresAtMillis; + } + + public Status status() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public boolean hasExpired() { + return Instant.now().isAfter(Instant.ofEpochMilli(expiresAtMillis)); + } + + public enum Type { + TPA, + TPAHERE + } + + public enum Status { + PENDING, + ACCEPTED, + DECLINED, + IGNORED + } + } + + private record TeleportPlan(Player teleporter, Location fixedDestination, UUID dynamicTargetId) { + } + + private record OptionalIntCooldown(boolean active, int seconds) { + private static OptionalIntCooldown inactive() { + return new OptionalIntCooldown(false, 0); + } + } + + private static final class PendingTeleport { + private final TeleportPlan plan; + private final Location startLocation; + private int remainingSeconds; + private BukkitTask task; + + private PendingTeleport(TeleportPlan plan, Location startLocation, int remainingSeconds) { + this.plan = plan; + this.startLocation = startLocation; + this.remainingSeconds = remainingSeconds; + } + + private TeleportPlan plan() { + return plan; + } + + private Location startLocation() { + return startLocation; + } + + private int remainingSeconds() { + return remainingSeconds; + } + + private void decrementRemainingSeconds() { + remainingSeconds--; + } + + private BukkitTask task() { + return task; + } + + private void setTask(BukkitTask task) { + this.task = task; + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1fd5cf8..a7bc42f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -37,6 +37,44 @@ tpsbar: # on: 始终启用插件实现;若服务端已有原生命令,则自动跳过以避免冲突 mode: auto +tpa: + # TPA 请求有效时间,单位为秒 + timeout-seconds: 60 + # 是否让 /tpa 接受后传送到接受时目标所在的位置快照,而不是传送完成时目标当前位置。 + strict-tpa-requests: false + # 是否让 /tpahere 接受后传送到请求发起时的位置,而不是请求者当前所在位置。 + # 该行为与 HuskHomes 的 strict_tpa_here_requests 默认值一致。 + strict-tpahere-requests: true + # 接受请求后的传送预热时间,单位为秒。设置为 0 可关闭预热。 + warmup-seconds: 5 + # 预热期间移动是否取消传送。 + cancel-warmup-on-move: true + # 预热期间受到伤害是否取消传送。 + cancel-warmup-on-damage: true + cooldowns: + # 是否启用 TPA 冷却系统。 + enabled: true + cooldown-times: + # 发送传送请求后的冷却时间,单位为秒。0 表示关闭。 + SEND_TELEPORT_REQUEST: 0 + # 接受传送请求后的冷却时间,单位为秒。0 表示关闭。 + ACCEPT_TELEPORT_REQUEST: 0 + # 判断移动取消的曼哈顿距离阈值。0.1 与 HuskHomes 行为接近,可避免只转头就取消。 + warmup-move-threshold: 0.1 + # 预热倒计时显示位置:actionbar / chat / none。 + warmup-display: actionbar + sounds: + # 是否播放 TPA 相关音效。 + enabled: true + # 收到请求时播放的音效。 + request-received: "entity.experience_orb.pickup" + # 预热倒计时时播放的音效。 + warmup: "block.note_block.banjo" + # 预热被取消时播放的音效。 + cancelled: "entity.item.break" + # 传送完成时播放的音效。 + complete: "entity.enderman.teleport" + blocks-menu: # 菜单布局版本,用于后续自动迁移槽位布局 layout-version: 2 diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 960e19f..3b964ad 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -82,10 +82,56 @@ help: seen: " &f/seen &7- View player information" feed: " &f/feed &7- Restore hunger" repair: " &f/repair &7- Repair held or all items" + tpa: " &f/tpa &7- Request to teleport to a player" + tpahere: " &f/tpahere &7- Request a player teleport to you" + tpaall: " &f/tpaall &7- Request all players teleport to you" + tpaccept: " &f/tpaccept [player] &7- Accept a teleport request" + tpdeny: " &f/tpdeny [player] &7- Deny a teleport request" + tpignore: " &f/tpignore &7- Toggle ignoring teleport requests" admin: " &f/essc admin &7- Toggle admin mode" tpsbar: " &f/tpsbar [player] &7- Toggle TPS boss bar" maintenance: " &f/maintenance &7- Manage maintenance mode" +tpa: + messages: + usage-tpa: "&cUsage: /tpa " + usage-tpahere: "&cUsage: /tpahere " + usage-tpaall: "&cUsage: /tpaall" + usage-tpaccept: "&cUsage: /tpaccept [player]" + usage-tpdeny: "&cUsage: /tpdeny [player]" + self: "&cYou cannot send a teleport request to yourself." + sent-tpa: "&aSent a teleport request to {target}. It expires in {seconds} seconds." + sent-tpahere: "&aInvited {target} to teleport to you. The request expires in {seconds} seconds." + duplicate-request: "&eYou already have a pending request of this type for {target}." + ignoring-requests: "&cYou are currently ignoring teleport requests." + send-cooldown: "&cPlease wait {seconds} seconds before sending another teleport request." + accept-cooldown: "&cPlease wait {seconds} seconds before accepting another teleport request." + received-tpa: "&e{requester} wants to teleport to you." + received-tpahere: "&e{requester} invited you to teleport to them." + response-hint: "&7Type &f/tpaccept {requester} &7to accept or &f/tpdeny {requester} &7to deny." + accept-button: "&a[Accept]" + deny-button: "&c[Deny]" + no-request: "&cYou do not have any pending teleport requests." + accepted-target: "&aAccepted {requester}'s teleport request." + accepted-sender: "&a{target} accepted your teleport request." + denied-target: "&cDenied {requester}'s teleport request." + denied-sender: "&c{target} denied your teleport request." + expired: "&cYour pending teleport request has expired." + expired-sender: "&cYour teleport request to {target} expired." + expired-target: "&cThe teleport request from {requester} expired." + player-offline: "&cTeleport failed because a player in the request is now offline." + already-warming-up: "&cA related player is already warming up for teleportation." + warmup-start: "&eTeleporting in {seconds} seconds. Do not move or take damage." + warmup-status: "&eTeleporting in {seconds} seconds" + warmup-cancelled-move: "&cTeleport cancelled because you moved during warmup." + warmup-cancelled-damage: "&cTeleport cancelled because you took damage during warmup." + teleport-complete: "&aTeleport complete." + ignore-enabled: "&eYou are now ignoring teleport requests." + ignore-disabled: "&aYou are now accepting teleport requests." + ignore-notification: "&7You are currently ignoring teleport requests." + tpaall-sent: "&aSent teleport requests to all players." + tpaall-no-targets: "&cThere are no online players who can receive the request." + blocks-menu: title: "&6&lEssentialsC &8- &e&lShortcut Menu" items: diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index a3cf6be..2a8b1cb 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -82,10 +82,56 @@ help: seen: " &f/seen &7- 查看玩家信息" feed: " &f/feed &7- 恢复饥饿值" repair: " &f/repair &7- 修复手中或全部物品" + tpa: " &f/tpa <玩家> &7- 请求传送到玩家身边" + tpahere: " &f/tpahere <玩家> &7- 请求玩家传送到你身边" + tpaall: " &f/tpaall &7- 向所有玩家发送传送到你身边的请求" + tpaccept: " &f/tpaccept [玩家] &7- 接受传送请求" + tpdeny: " &f/tpdeny [玩家] &7- 拒绝传送请求" + tpignore: " &f/tpignore &7- 切换是否忽略传送请求" admin: " &f/essc admin &7- 切换管理模式" tpsbar: " &f/tpsbar [玩家] &7- 切换 TPS 状态栏" maintenance: " &f/maintenance &7- 管理维护模式" +tpa: + messages: + usage-tpa: "&c用法:/tpa <玩家>" + usage-tpahere: "&c用法:/tpahere <玩家>" + usage-tpaall: "&c用法:/tpaall" + usage-tpaccept: "&c用法:/tpaccept [玩家]" + usage-tpdeny: "&c用法:/tpdeny [玩家]" + self: "&c你不能向自己发送传送请求。" + sent-tpa: "&a已向 {target} 发送传送请求,{seconds} 秒后过期。" + sent-tpahere: "&a已邀请 {target} 传送到你身边,{seconds} 秒后过期。" + duplicate-request: "&e你已经向 {target} 发送过同类型传送请求,请等待对方处理。" + ignoring-requests: "&c你当前正在忽略传送请求。" + send-cooldown: "&c请等待 {seconds} 秒后再发送传送请求。" + accept-cooldown: "&c请等待 {seconds} 秒后再接受传送请求。" + received-tpa: "&e{requester} 请求传送到你身边。" + received-tpahere: "&e{requester} 邀请你传送到对方身边。" + response-hint: "&7输入 &f/tpaccept {requester} &7接受,或 &f/tpdeny {requester} &7拒绝。" + accept-button: "&a[接受]" + deny-button: "&c[拒绝]" + no-request: "&c你没有待处理的传送请求。" + accepted-target: "&a你已接受 {requester} 的传送请求。" + accepted-sender: "&a{target} 已接受你的传送请求。" + denied-target: "&c你已拒绝 {requester} 的传送请求。" + denied-sender: "&c{target} 拒绝了你的传送请求。" + expired: "&c你的待处理传送请求已过期。" + expired-sender: "&c你发给 {target} 的传送请求已过期。" + expired-target: "&c来自 {requester} 的传送请求已过期。" + player-offline: "&c无法完成传送,请求中的玩家已离线。" + already-warming-up: "&c相关玩家已有一个正在预热的传送。" + warmup-start: "&e传送将在 {seconds} 秒后开始,请不要移动或受到伤害。" + warmup-status: "&e传送倒计时:{seconds} 秒" + warmup-cancelled-move: "&c传送已取消:你在预热期间移动了。" + warmup-cancelled-damage: "&c传送已取消:你在预热期间受到了伤害。" + teleport-complete: "&a传送完成。" + ignore-enabled: "&e你已开始忽略传送请求。" + ignore-disabled: "&a你已恢复接收传送请求。" + ignore-notification: "&7你当前正在忽略传送请求。" + tpaall-sent: "&a已向所有玩家发送传送请求。" + tpaall-no-targets: "&c没有可接收请求的在线玩家。" + blocks-menu: title: "&6&lEssentialsC &8- &e&l便捷菜单" items: diff --git a/src/main/resources/modules.yml b/src/main/resources/modules.yml index e3b8256..056c82b 100644 --- a/src/main/resources/modules.yml +++ b/src/main/resources/modules.yml @@ -13,7 +13,7 @@ modules: # 工作台、铁砧等便捷方块命令、/essc blocks 菜单和潜影盒快捷打开。 enabled: true player: - # 玩家常用命令:飞行、夜视、发光、治疗、喂食、修复、帽子、自杀、隐身和查询玩家。 + # 玩家常用命令:飞行、夜视、发光、治疗、喂食、修复、帽子、自杀、隐身、查询玩家和 TPA。 enabled: true admin-mode: # /essc admin 管理模式,以及独立背包和状态管理。 diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index c874a87..281a840 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -77,6 +77,30 @@ permissions: essentialsc.command.repair.all: description: 允许使用 /repair all default: op + essentialsc.command.tpa: + description: 允许发送 /tpa 请求 + default: true + essentialsc.command.tpahere: + description: 允许发送 /tpahere 请求 + default: true + essentialsc.command.tpaall: + description: 允许向所有玩家发送 /tpahere 请求 + default: op + essentialsc.command.tpaccept: + description: 允许接受 TPA 请求 + default: true + essentialsc.command.tpdeny: + description: 允许拒绝 TPA 请求 + default: true + essentialsc.command.tpignore: + description: 允许切换是否忽略 TPA 请求 + default: true + essentialsc.tpa.bypass-warmup: + description: 允许绕过 TPA 传送预热 + default: op + essentialsc.tpa.bypass-cooldown: + description: 允许绕过 TPA 冷却 + default: op essentialsc.command.admin: description: 允许使用 /essc admin default: op @@ -133,6 +157,14 @@ permissions: essentialsc.command.feed.others: true essentialsc.command.repair: true essentialsc.command.repair.all: true + essentialsc.command.tpa: true + essentialsc.command.tpahere: true + essentialsc.command.tpaall: true + essentialsc.command.tpaccept: true + essentialsc.command.tpdeny: true + essentialsc.command.tpignore: true + essentialsc.tpa.bypass-warmup: true + essentialsc.tpa.bypass-cooldown: true essentialsc.command.admin: true essentialsc.command.help: true essentialsc.command.reload: true diff --git a/versions/v1_21_11/build.gradle b/versions/v1_21_11/build.gradle new file mode 100644 index 0000000..bb17e82 --- /dev/null +++ b/versions/v1_21_11/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java-library' + id 'io.papermc.paperweight.userdev' +} + +repositories { + mavenCentral() + maven { + name = 'papermc' + url = uri('https://repo.papermc.io/repository/maven-public/') + } +} + +dependencies { + api project(':compat-api') + paperweight.paperDevBundle('1.21.11-R0.1-SNAPSHOT') +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) +} + +paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArtifactConfiguration.getMOJANG_PRODUCTION() + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} diff --git a/versions/v1_21_11/src/main/java/cn/infstar/essentialsC/v1_21_11/jei/JeiRecipeSyncAdapterImpl.java b/versions/v1_21_11/src/main/java/cn/infstar/essentialsC/v1_21_11/jei/JeiRecipeSyncAdapterImpl.java new file mode 100644 index 0000000..78297da --- /dev/null +++ b/versions/v1_21_11/src/main/java/cn/infstar/essentialsC/v1_21_11/jei/JeiRecipeSyncAdapterImpl.java @@ -0,0 +1,239 @@ +package cn.infstar.essentialsC.v1_21_11.jei; + +import cn.infstar.essentialsC.compat.jei.JeiRecipeSyncAdapter; +import io.netty.buffer.Unpooled; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket; +import net.minecraft.network.protocol.common.custom.DiscardedPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeMap; +import net.minecraft.world.item.crafting.RecipeSerializer; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.logging.Logger; + +public final class JeiRecipeSyncAdapterImpl implements JeiRecipeSyncAdapter { + + @Override + @SuppressWarnings({"unchecked", "deprecation"}) + public void sendFabricRecipeSync(Player player, Logger logger, boolean debug) { + ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + MinecraftServer server = serverPlayer.level().getServer(); + if (server == null) { + if (debug) { + logger.warning("服务端实例为 null"); + } + return; + } + + RecipeMap recipeMap = server.getRecipeManager().recipes; + if (debug) { + logger.info("开始构建 Fabric 配方数据"); + } + + var list = new ArrayList(); + var seen = new HashSet>(); + + for (RecipeSerializer serializer : BuiltInRegistries.RECIPE_SERIALIZER) { + if (!seen.add(serializer)) { + continue; + } + + List> recipes = new ArrayList<>(); + for (RecipeHolder holder : recipeMap.values()) { + if (holder.value().getSerializer() == serializer) { + recipes.add(holder); + } + } + + if (!recipes.isEmpty()) { + RecipeSerializer entrySerializer = recipes.get(0).value().getSerializer(); + list.add(new FabricRecipeEntry(entrySerializer, recipes)); + } + } + + var payload = new FabricRecipeSyncPayload(list); + if (debug) { + logger.info("Fabric 配方条目数: " + list.size()); + } + + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), server.registryAccess()); + getFabricCodec().encode(buffer, payload); + + byte[] bytes = new byte[buffer.writerIndex()]; + buffer.getBytes(0, bytes); + + Identifier id = Identifier.fromNamespaceAndPath("fabric", "recipe_sync"); + DiscardedPayload discardedPayload = new DiscardedPayload(id, bytes); + serverPlayer.connection.send(new ClientboundCustomPayloadPacket(discardedPayload)); + + if (debug) { + logger.info("已发送 Fabric 配方同步 [" + id + "], 大小: " + bytes.length + " bytes"); + } + } + + @Override + @SuppressWarnings({"unchecked", "deprecation"}) + public void sendNeoForgeRecipeSync(Player player, Logger logger, boolean debug) { + ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + MinecraftServer server = serverPlayer.level().getServer(); + if (server == null) { + if (debug) { + logger.warning("服务端实例为 null"); + } + return; + } + + RecipeMap recipeMap = server.getRecipeManager().recipes; + if (debug) { + logger.info("开始构建 NeoForge 配方数据"); + } + + java.util.List> allRecipeTypes = + BuiltInRegistries.RECIPE_TYPE.stream().toList(); + if (debug) { + logger.info("NeoForge 配方类型数: " + allRecipeTypes.size()); + } + + var payload = createNeoForgePayload(allRecipeTypes, recipeMap); + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), server.registryAccess()); + getNeoForgeStreamCodec().encode(buffer, payload); + + byte[] bytes = new byte[buffer.writerIndex()]; + buffer.getBytes(0, bytes); + + Identifier id = Identifier.fromNamespaceAndPath("neoforge", "recipe_content"); + DiscardedPayload discardedPayload = new DiscardedPayload(id, bytes); + serverPlayer.connection.send(new ClientboundCustomPayloadPacket(discardedPayload)); + serverPlayer.connection.send(new net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket( + net.minecraft.tags.TagNetworkSerialization.serializeTagsToNetwork(server.registries()) + )); + + if (debug) { + logger.info("已发送 NeoForge 配方同步 [" + id + "], 大小: " + bytes.length + " bytes"); + } + } + + @SuppressWarnings({"unchecked", "deprecation"}) + private net.minecraft.network.codec.StreamCodec getFabricCodec() { + return FabricRecipeEntry.CODEC.apply(net.minecraft.network.codec.ByteBufCodecs.list()) + .map(FabricRecipeSyncPayload::new, FabricRecipeSyncPayload::entries); + } + + private NeoForgeRecipeSyncPayload createNeoForgePayload( + java.util.List> recipeTypes, + RecipeMap recipeMap + ) { + var recipeTypeSet = new java.util.HashSet<>(recipeTypes); + if (recipeTypeSet.isEmpty()) { + return new NeoForgeRecipeSyncPayload(recipeTypeSet, java.util.List.of()); + } + + var recipeSubset = recipeMap.values().stream() + .filter(h -> recipeTypeSet.contains(h.value().getType())) + .toList(); + return new NeoForgeRecipeSyncPayload(recipeTypeSet, recipeSubset); + } + + @SuppressWarnings({"unchecked", "deprecation"}) + private net.minecraft.network.codec.StreamCodec getNeoForgeStreamCodec() { + return net.minecraft.network.codec.StreamCodec.composite( + net.minecraft.network.codec.ByteBufCodecs.registry(net.minecraft.core.registries.Registries.RECIPE_TYPE) + .apply(net.minecraft.network.codec.ByteBufCodecs.collection(java.util.HashSet::new)), + NeoForgeRecipeSyncPayload::recipeTypes, + RecipeHolder.STREAM_CODEC.apply(net.minecraft.network.codec.ByteBufCodecs.list()), + NeoForgeRecipeSyncPayload::recipes, + NeoForgeRecipeSyncPayload::new + ); + } + + @SuppressWarnings("deprecation") + private static class FabricRecipeEntry { + final Object serializer; + final List> recipes; + + FabricRecipeEntry(Object serializer, List> recipes) { + this.serializer = serializer; + this.recipes = recipes; + } + + static final net.minecraft.network.codec.StreamCodec CODEC = + net.minecraft.network.codec.StreamCodec.ofMember( + FabricRecipeEntry::write, + FabricRecipeEntry::read + ); + + @SuppressWarnings("unchecked") + private static FabricRecipeEntry read(RegistryFriendlyByteBuf buf) { + Identifier recipeSerializerId = buf.readIdentifier(); + RecipeSerializer recipeSerializer = BuiltInRegistries.RECIPE_SERIALIZER.getValue(recipeSerializerId); + + if (recipeSerializer == null) { + throw new RuntimeException("Tried syncing unsupported packet serializer '" + recipeSerializerId + "'!"); + } + + int count = buf.readVarInt(); + var list = new ArrayList>(); + for (int i = 0; i < count; i++) { + net.minecraft.resources.ResourceKey> id = + buf.readResourceKey(net.minecraft.core.registries.Registries.RECIPE); + + try { + var streamCodecMethod = recipeSerializer.getClass().getMethod("streamCodec"); + var streamCodec = streamCodecMethod.invoke(recipeSerializer); + net.minecraft.world.item.crafting.Recipe recipe = + ((net.minecraft.network.codec.StreamCodec>) streamCodec) + .decode(buf); + list.add(new RecipeHolder<>(id, recipe)); + } catch (Exception e) { + throw new RuntimeException("Failed to decode recipe: " + e.getMessage(), e); + } + } + + return new FabricRecipeEntry(recipeSerializer, list); + } + + private void write(RegistryFriendlyByteBuf buf) { + try { + var getKeyMethod = BuiltInRegistries.RECIPE_SERIALIZER.getClass().getMethod("getKey", Object.class); + Identifier identifier = (Identifier) getKeyMethod.invoke(BuiltInRegistries.RECIPE_SERIALIZER, this.serializer); + buf.writeIdentifier(identifier); + } catch (Exception e) { + throw new RuntimeException("Failed to get serializer key: " + e.getMessage(), e); + } + + buf.writeVarInt(this.recipes.size()); + + try { + var streamCodecMethod = this.serializer.getClass().getMethod("streamCodec"); + @SuppressWarnings("unchecked") + var codec = (net.minecraft.network.codec.StreamCodec>) + streamCodecMethod.invoke(this.serializer); + + for (RecipeHolder recipe : this.recipes) { + buf.writeResourceKey(recipe.id()); + codec.encode(buf, recipe.value()); + } + } catch (Exception e) { + throw new RuntimeException("Failed to encode recipe: " + e.getMessage(), e); + } + } + } + + private record FabricRecipeSyncPayload(List entries) { + } + + private record NeoForgeRecipeSyncPayload( + java.util.Set> recipeTypes, + java.util.List> recipes + ) { + } +} diff --git a/versions/v26_1_2/build.gradle b/versions/v26_1_2/build.gradle new file mode 100644 index 0000000..6706de6 --- /dev/null +++ b/versions/v26_1_2/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java-library' + id 'io.papermc.paperweight.userdev' +} + +repositories { + mavenCentral() + maven { + name = 'papermc' + url = uri('https://repo.papermc.io/repository/maven-public/') + } +} + +dependencies { + api project(':compat-api') + paperweight.paperDevBundle('26.1.2.build.72-stable') +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(25)) +} + +paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArtifactConfiguration.getMOJANG_PRODUCTION() + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} diff --git a/versions/v26_1_2/src/main/java/cn/infstar/essentialsC/v26_1_2/jei/JeiRecipeSyncAdapterImpl.java b/versions/v26_1_2/src/main/java/cn/infstar/essentialsC/v26_1_2/jei/JeiRecipeSyncAdapterImpl.java new file mode 100644 index 0000000..3a1f060 --- /dev/null +++ b/versions/v26_1_2/src/main/java/cn/infstar/essentialsC/v26_1_2/jei/JeiRecipeSyncAdapterImpl.java @@ -0,0 +1,240 @@ +package cn.infstar.essentialsC.v26_1_2.jei; + +import cn.infstar.essentialsC.compat.jei.JeiRecipeSyncAdapter; +import io.netty.buffer.Unpooled; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket; +import net.minecraft.network.protocol.common.custom.DiscardedPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeMap; +import net.minecraft.world.item.crafting.RecipeSerializer; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.logging.Logger; + +public final class JeiRecipeSyncAdapterImpl implements JeiRecipeSyncAdapter { + + @Override + @SuppressWarnings({"unchecked", "deprecation"}) + public void sendFabricRecipeSync(Player player, Logger logger, boolean debug) { + ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + MinecraftServer server = serverPlayer.level().getServer(); + if (server == null) { + if (debug) { + logger.warning("服务端实例为 null"); + } + return; + } + + RecipeMap recipeMap = server.getRecipeManager().recipes; + if (debug) { + logger.info("开始构建 Fabric 配方数据"); + } + + var list = new ArrayList(); + var seen = new HashSet>(); + + for (RecipeSerializer serializer : BuiltInRegistries.RECIPE_SERIALIZER) { + if (!seen.add(serializer)) { + continue; + } + + List> recipes = new ArrayList<>(); + for (RecipeHolder holder : recipeMap.values()) { + if (holder.value().getSerializer() == serializer) { + recipes.add(holder); + } + } + + if (!recipes.isEmpty()) { + RecipeSerializer entrySerializer = recipes.get(0).value().getSerializer(); + list.add(new FabricRecipeEntry(entrySerializer, recipes)); + } + } + + var payload = new FabricRecipeSyncPayload(list); + if (debug) { + logger.info("Fabric 配方条目数: " + list.size()); + } + + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), server.registryAccess()); + getFabricCodec().encode(buffer, payload); + + byte[] bytes = new byte[buffer.writerIndex()]; + buffer.getBytes(0, bytes); + + Identifier id = Identifier.fromNamespaceAndPath("fabric", "recipe_sync"); + DiscardedPayload discardedPayload = new DiscardedPayload(id, bytes); + serverPlayer.connection.send(new ClientboundCustomPayloadPacket(discardedPayload)); + + if (debug) { + logger.info("已发送 Fabric 配方同步 [" + id + "], 大小: " + bytes.length + " bytes"); + } + } + + @Override + @SuppressWarnings({"unchecked", "deprecation"}) + public void sendNeoForgeRecipeSync(Player player, Logger logger, boolean debug) { + ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + MinecraftServer server = serverPlayer.level().getServer(); + if (server == null) { + if (debug) { + logger.warning("服务端实例为 null"); + } + return; + } + + RecipeMap recipeMap = server.getRecipeManager().recipes; + if (debug) { + logger.info("开始构建 NeoForge 配方数据"); + } + + java.util.List> allRecipeTypes = + BuiltInRegistries.RECIPE_TYPE.stream().toList(); + if (debug) { + logger.info("NeoForge 配方类型数: " + allRecipeTypes.size()); + } + + var payload = createNeoForgePayload(allRecipeTypes, recipeMap); + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), server.registryAccess()); + getNeoForgeStreamCodec().encode(buffer, payload); + + byte[] bytes = new byte[buffer.writerIndex()]; + buffer.getBytes(0, bytes); + + Identifier id = Identifier.fromNamespaceAndPath("neoforge", "recipe_content"); + DiscardedPayload discardedPayload = new DiscardedPayload(id, bytes); + serverPlayer.connection.send(new ClientboundCustomPayloadPacket(discardedPayload)); + serverPlayer.connection.send(new net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket( + net.minecraft.tags.TagNetworkSerialization.serializeTagsToNetwork(server.registries()) + )); + + if (debug) { + logger.info("已发送 NeoForge 配方同步 [" + id + "], 大小: " + bytes.length + " bytes"); + } + } + + @SuppressWarnings({"unchecked", "deprecation"}) + private net.minecraft.network.codec.StreamCodec getFabricCodec() { + return FabricRecipeEntry.CODEC.apply(net.minecraft.network.codec.ByteBufCodecs.list()) + .map(FabricRecipeSyncPayload::new, FabricRecipeSyncPayload::entries); + } + + private NeoForgeRecipeSyncPayload createNeoForgePayload( + java.util.List> recipeTypes, + RecipeMap recipeMap + ) { + var recipeTypeSet = new java.util.HashSet<>(recipeTypes); + if (recipeTypeSet.isEmpty()) { + return new NeoForgeRecipeSyncPayload(recipeTypeSet, java.util.List.of()); + } + + var recipeSubset = recipeMap.values().stream() + .filter(h -> recipeTypeSet.contains(h.value().getType())) + .toList(); + return new NeoForgeRecipeSyncPayload(recipeTypeSet, recipeSubset); + } + + @SuppressWarnings({"unchecked", "deprecation"}) + private net.minecraft.network.codec.StreamCodec getNeoForgeStreamCodec() { + return net.minecraft.network.codec.StreamCodec.composite( + net.minecraft.network.codec.ByteBufCodecs.registry(net.minecraft.core.registries.Registries.RECIPE_TYPE) + .apply(net.minecraft.network.codec.ByteBufCodecs.collection(java.util.HashSet::new)), + NeoForgeRecipeSyncPayload::recipeTypes, + RecipeHolder.STREAM_CODEC.apply(net.minecraft.network.codec.ByteBufCodecs.list()), + NeoForgeRecipeSyncPayload::recipes, + NeoForgeRecipeSyncPayload::new + ); + } + + @SuppressWarnings("deprecation") + private static class FabricRecipeEntry { + final Object serializer; + final List> recipes; + + FabricRecipeEntry(Object serializer, List> recipes) { + this.serializer = serializer; + this.recipes = recipes; + } + + static final net.minecraft.network.codec.StreamCodec CODEC = + net.minecraft.network.codec.StreamCodec.ofMember( + FabricRecipeEntry::write, + FabricRecipeEntry::read + ); + + @SuppressWarnings("unchecked") + private static FabricRecipeEntry read(RegistryFriendlyByteBuf buf) { + Identifier recipeSerializerId = buf.readIdentifier(); + RecipeSerializer recipeSerializer = BuiltInRegistries.RECIPE_SERIALIZER.getValue(recipeSerializerId); + + if (recipeSerializer == null) { + throw new RuntimeException("Tried syncing unsupported packet serializer '" + recipeSerializerId + "'!"); + } + + int count = buf.readVarInt(); + var list = new ArrayList>(); + for (int i = 0; i < count; i++) { + net.minecraft.resources.ResourceKey> id = + buf.readResourceKey(net.minecraft.core.registries.Registries.RECIPE); + + try { + var streamCodecMethod = recipeSerializer.getClass().getMethod("streamCodec"); + var streamCodec = streamCodecMethod.invoke(recipeSerializer); + net.minecraft.world.item.crafting.Recipe recipe = + ((net.minecraft.network.codec.StreamCodec>) streamCodec) + .decode(buf); + list.add(new RecipeHolder<>(id, recipe)); + } catch (Exception e) { + throw new RuntimeException("Failed to decode recipe: " + e.getMessage(), e); + } + } + + return new FabricRecipeEntry(recipeSerializer, list); + } + + private void write(RegistryFriendlyByteBuf buf) { + try { + var getKeyMethod = BuiltInRegistries.RECIPE_SERIALIZER.getClass().getMethod("getKey", Object.class); + Identifier identifier = (Identifier) getKeyMethod.invoke(BuiltInRegistries.RECIPE_SERIALIZER, this.serializer); + buf.writeIdentifier(identifier); + } catch (Exception e) { + throw new RuntimeException("Failed to get serializer key: " + e.getMessage(), e); + } + + buf.writeVarInt(this.recipes.size()); + + try { + var streamCodecMethod = this.serializer.getClass().getMethod("streamCodec"); + @SuppressWarnings("unchecked") + var codec = (net.minecraft.network.codec.StreamCodec>) + streamCodecMethod.invoke(this.serializer); + + for (RecipeHolder recipe : this.recipes) { + buf.writeResourceKey(recipe.id()); + codec.encode(buf, recipe.value()); + } + } catch (Exception e) { + throw new RuntimeException("Failed to encode recipe: " + e.getMessage(), e); + } + } + } + + private record FabricRecipeSyncPayload(List entries) { + } + + private record NeoForgeRecipeSyncPayload( + java.util.Set> recipeTypes, + java.util.List> recipes + ) { + } +} + diff --git a/versions/v26_2/build.gradle b/versions/v26_2/build.gradle new file mode 100644 index 0000000..3072c2a --- /dev/null +++ b/versions/v26_2/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java-library' + id 'io.papermc.paperweight.userdev' +} + +repositories { + mavenCentral() + maven { + name = 'papermc' + url = uri('https://repo.papermc.io/repository/maven-public/') + } +} + +dependencies { + api project(':compat-api') + paperweight.paperDevBundle('26.2.build.31-alpha') +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(25)) +} + +paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArtifactConfiguration.getMOJANG_PRODUCTION() + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} diff --git a/versions/v26_2/src/main/java/cn/infstar/essentialsC/v26_2/jei/JeiRecipeSyncAdapterImpl.java b/versions/v26_2/src/main/java/cn/infstar/essentialsC/v26_2/jei/JeiRecipeSyncAdapterImpl.java new file mode 100644 index 0000000..cb6ac43 --- /dev/null +++ b/versions/v26_2/src/main/java/cn/infstar/essentialsC/v26_2/jei/JeiRecipeSyncAdapterImpl.java @@ -0,0 +1,240 @@ +package cn.infstar.essentialsC.v26_2.jei; + +import cn.infstar.essentialsC.compat.jei.JeiRecipeSyncAdapter; +import io.netty.buffer.Unpooled; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket; +import net.minecraft.network.protocol.common.custom.DiscardedPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeMap; +import net.minecraft.world.item.crafting.RecipeSerializer; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.logging.Logger; + +public final class JeiRecipeSyncAdapterImpl implements JeiRecipeSyncAdapter { + + @Override + @SuppressWarnings({"unchecked", "deprecation"}) + public void sendFabricRecipeSync(Player player, Logger logger, boolean debug) { + ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + MinecraftServer server = serverPlayer.level().getServer(); + if (server == null) { + if (debug) { + logger.warning("服务端实例为 null"); + } + return; + } + + RecipeMap recipeMap = server.getRecipeManager().recipes; + if (debug) { + logger.info("开始构建 Fabric 配方数据"); + } + + var list = new ArrayList(); + var seen = new HashSet>(); + + for (RecipeSerializer serializer : BuiltInRegistries.RECIPE_SERIALIZER) { + if (!seen.add(serializer)) { + continue; + } + + List> recipes = new ArrayList<>(); + for (RecipeHolder holder : recipeMap.values()) { + if (holder.value().getSerializer() == serializer) { + recipes.add(holder); + } + } + + if (!recipes.isEmpty()) { + RecipeSerializer entrySerializer = recipes.get(0).value().getSerializer(); + list.add(new FabricRecipeEntry(entrySerializer, recipes)); + } + } + + var payload = new FabricRecipeSyncPayload(list); + if (debug) { + logger.info("Fabric 配方条目数: " + list.size()); + } + + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), server.registryAccess()); + getFabricCodec().encode(buffer, payload); + + byte[] bytes = new byte[buffer.writerIndex()]; + buffer.getBytes(0, bytes); + + Identifier id = Identifier.fromNamespaceAndPath("fabric", "recipe_sync"); + DiscardedPayload discardedPayload = new DiscardedPayload(id, bytes); + serverPlayer.connection.send(new ClientboundCustomPayloadPacket(discardedPayload)); + + if (debug) { + logger.info("已发送 Fabric 配方同步 [" + id + "], 大小: " + bytes.length + " bytes"); + } + } + + @Override + @SuppressWarnings({"unchecked", "deprecation"}) + public void sendNeoForgeRecipeSync(Player player, Logger logger, boolean debug) { + ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + MinecraftServer server = serverPlayer.level().getServer(); + if (server == null) { + if (debug) { + logger.warning("服务端实例为 null"); + } + return; + } + + RecipeMap recipeMap = server.getRecipeManager().recipes; + if (debug) { + logger.info("开始构建 NeoForge 配方数据"); + } + + java.util.List> allRecipeTypes = + BuiltInRegistries.RECIPE_TYPE.stream().toList(); + if (debug) { + logger.info("NeoForge 配方类型数: " + allRecipeTypes.size()); + } + + var payload = createNeoForgePayload(allRecipeTypes, recipeMap); + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), server.registryAccess()); + getNeoForgeStreamCodec().encode(buffer, payload); + + byte[] bytes = new byte[buffer.writerIndex()]; + buffer.getBytes(0, bytes); + + Identifier id = Identifier.fromNamespaceAndPath("neoforge", "recipe_content"); + DiscardedPayload discardedPayload = new DiscardedPayload(id, bytes); + serverPlayer.connection.send(new ClientboundCustomPayloadPacket(discardedPayload)); + serverPlayer.connection.send(new net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket( + net.minecraft.tags.TagNetworkSerialization.serializeTagsToNetwork(server.registries()) + )); + + if (debug) { + logger.info("已发送 NeoForge 配方同步 [" + id + "], 大小: " + bytes.length + " bytes"); + } + } + + @SuppressWarnings({"unchecked", "deprecation"}) + private net.minecraft.network.codec.StreamCodec getFabricCodec() { + return FabricRecipeEntry.CODEC.apply(net.minecraft.network.codec.ByteBufCodecs.list()) + .map(FabricRecipeSyncPayload::new, FabricRecipeSyncPayload::entries); + } + + private NeoForgeRecipeSyncPayload createNeoForgePayload( + java.util.List> recipeTypes, + RecipeMap recipeMap + ) { + var recipeTypeSet = new java.util.HashSet<>(recipeTypes); + if (recipeTypeSet.isEmpty()) { + return new NeoForgeRecipeSyncPayload(recipeTypeSet, java.util.List.of()); + } + + var recipeSubset = recipeMap.values().stream() + .filter(h -> recipeTypeSet.contains(h.value().getType())) + .toList(); + return new NeoForgeRecipeSyncPayload(recipeTypeSet, recipeSubset); + } + + @SuppressWarnings({"unchecked", "deprecation"}) + private net.minecraft.network.codec.StreamCodec getNeoForgeStreamCodec() { + return net.minecraft.network.codec.StreamCodec.composite( + net.minecraft.network.codec.ByteBufCodecs.registry(net.minecraft.core.registries.Registries.RECIPE_TYPE) + .apply(net.minecraft.network.codec.ByteBufCodecs.collection(java.util.HashSet::new)), + NeoForgeRecipeSyncPayload::recipeTypes, + RecipeHolder.STREAM_CODEC.apply(net.minecraft.network.codec.ByteBufCodecs.list()), + NeoForgeRecipeSyncPayload::recipes, + NeoForgeRecipeSyncPayload::new + ); + } + + @SuppressWarnings("deprecation") + private static class FabricRecipeEntry { + final Object serializer; + final List> recipes; + + FabricRecipeEntry(Object serializer, List> recipes) { + this.serializer = serializer; + this.recipes = recipes; + } + + static final net.minecraft.network.codec.StreamCodec CODEC = + net.minecraft.network.codec.StreamCodec.ofMember( + FabricRecipeEntry::write, + FabricRecipeEntry::read + ); + + @SuppressWarnings("unchecked") + private static FabricRecipeEntry read(RegistryFriendlyByteBuf buf) { + Identifier recipeSerializerId = buf.readIdentifier(); + RecipeSerializer recipeSerializer = BuiltInRegistries.RECIPE_SERIALIZER.getValue(recipeSerializerId); + + if (recipeSerializer == null) { + throw new RuntimeException("Tried syncing unsupported packet serializer '" + recipeSerializerId + "'!"); + } + + int count = buf.readVarInt(); + var list = new ArrayList>(); + for (int i = 0; i < count; i++) { + net.minecraft.resources.ResourceKey> id = + buf.readResourceKey(net.minecraft.core.registries.Registries.RECIPE); + + try { + var streamCodecMethod = recipeSerializer.getClass().getMethod("streamCodec"); + var streamCodec = streamCodecMethod.invoke(recipeSerializer); + net.minecraft.world.item.crafting.Recipe recipe = + ((net.minecraft.network.codec.StreamCodec>) streamCodec) + .decode(buf); + list.add(new RecipeHolder<>(id, recipe)); + } catch (Exception e) { + throw new RuntimeException("Failed to decode recipe: " + e.getMessage(), e); + } + } + + return new FabricRecipeEntry(recipeSerializer, list); + } + + private void write(RegistryFriendlyByteBuf buf) { + try { + var getKeyMethod = BuiltInRegistries.RECIPE_SERIALIZER.getClass().getMethod("getKey", Object.class); + Identifier identifier = (Identifier) getKeyMethod.invoke(BuiltInRegistries.RECIPE_SERIALIZER, this.serializer); + buf.writeIdentifier(identifier); + } catch (Exception e) { + throw new RuntimeException("Failed to get serializer key: " + e.getMessage(), e); + } + + buf.writeVarInt(this.recipes.size()); + + try { + var streamCodecMethod = this.serializer.getClass().getMethod("streamCodec"); + @SuppressWarnings("unchecked") + var codec = (net.minecraft.network.codec.StreamCodec>) + streamCodecMethod.invoke(this.serializer); + + for (RecipeHolder recipe : this.recipes) { + buf.writeResourceKey(recipe.id()); + codec.encode(buf, recipe.value()); + } + } catch (Exception e) { + throw new RuntimeException("Failed to encode recipe: " + e.getMessage(), e); + } + } + } + + private record FabricRecipeSyncPayload(List entries) { + } + + private record NeoForgeRecipeSyncPayload( + java.util.Set> recipeTypes, + java.util.List> recipes + ) { + } +} +