From 5a1ea2343ec93e38604e754e80b38aee2e932e0d Mon Sep 17 00:00:00 2001 From: SashegDev Date: Sat, 17 Oct 2026 15:52:28 +0000 Subject: [PATCH] feat(void): persisted void counter and solo entry into the void world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Девятый этап и вход в пустоту: счётчик в файле плюс возможность войти в одиночку. Счётчик void: - живёт в plugins/BlackKnife/void.yml, по условию в файле, а не в PDC - пишется через временный файл и атомарную подмену: при убийстве сервера в момент записи остаётся предыдущая целая версия, а не обрезанный файл - сохранение раз в минуту асинхронно и синхронно при выключении, потому что после cancelTasks асинхронная задача уже не выполнится - порог 20, команда /blackknife void get|set|add [player] — нужна и для настройки баланса, и для проверки: двадцать нор ждать долго Вход в пустоту: - при счётчике от 20 дыра работает и без ответа: в неё можно войти и оказаться в пустоте - пускает ТОЛЬКО открывший. Иначе счётчик превратился бы в телепорт для всех, и весь смысл накопления теряется - точка входа запоминается: обратный выход пойдёт по соотношению 1:2, поэтому выход не равен входу по координатам - приземление ищется через getHighestBlockYAt, а не вычисляется: высота, на которую лягут слои барьера, зависит от версии, а поиск верхнего блока находит пол всегда Voidness копится раз в секунду (период задачи эмбиента ровно 60 тиков) и обнуляется на выходе: счётчик сессионный, рестарт его тоже убирает. Найденное имя измерения: world_delta_void_void_world. Сервер добавляет префикс папки мира и санитизирует путь, так что угадать его в коде было бы верным способом не заработать. Проверка по имени мира, а не по флагам уровня. Проверено на сервере: команда отвечает, измерение находится, генератор кладёт барьерные слои на нескольких высотах. Сам перенос игрока не проверялся — нужен живой игрок. --- .../blackknife/BlackKnifeCommand.java | 77 ++++++++- .../blackknife/BlackKnifePlugin.java | 7 + .../me/sashegdev/blackknife/MoleHole.java | 32 +++- .../me/sashegdev/blackknife/VoidCounter.java | 161 ++++++++++++++++++ .../me/sashegdev/blackknife/VoidWorld.java | 79 ++++++++- 5 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 src/main/java/me/sashegdev/blackknife/VoidCounter.java diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifeCommand.java b/src/main/java/me/sashegdev/blackknife/BlackKnifeCommand.java index c443454..05f2b1f 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifeCommand.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifeCommand.java @@ -13,7 +13,7 @@ import java.util.Collections; import java.util.List; public class BlackKnifeCommand implements CommandExecutor, TabCompleter { - private static final List SUBS = Arrays.asList("give", "reload", "mode", "tp"); + private static final List SUBS = Arrays.asList("give", "reload", "mode", "tp", "void"); private static final List TP_OPS = Arrays.asList("get", "set", "add"); private final BlackKnifePlugin plugin; @@ -78,10 +78,77 @@ public class BlackKnifeCommand implements CommandExecutor, TabCompleter { sender.sendMessage(Component.text("Usage: /blackknife tp [amount]").color(NamedTextColor.YELLOW)); return true; } + if (args[0].equalsIgnoreCase("void")) { + voidCounter(sender, args); + return true; + } usage(sender); return true; } + /** + * Счётчик пустоты: сколько пар нор открыл игрок. + * + *

Нужен и для настройки баланса, и для проверки входа в пустоту: порог 20 + * набирается долго, а чтобы посмотреть, что там внутри, ждать двадцать нор + * не нужно. + */ + private void voidCounter(CommandSender sender, String[] args) { + if (args.length == 1 || args[1].equalsIgnoreCase("get")) { + if (args.length >= 3) { + final Player t = BlackKnife.player(args[2]); + if (t == null) { + sender.sendMessage(Component.text("Player not found").color(NamedTextColor.RED)); + return; + } + sender.sendMessage(Component.text("Void of " + t.getName() + ": " + + VoidCounter.get(t) + " / " + VoidCounter.THRESHOLD) + .color(NamedTextColor.LIGHT_PURPLE)); + } else if (sender instanceof Player p) { + sender.sendMessage(Component.text("Your void: " + VoidCounter.get(p) + + " / " + VoidCounter.THRESHOLD).color(NamedTextColor.LIGHT_PURPLE)); + } else { + sender.sendMessage(Component.text("Usage: /blackknife void get [player]") + .color(NamedTextColor.YELLOW)); + } + return; + } + + final boolean add = args[1].equalsIgnoreCase("add"); + if (!add && !args[1].equalsIgnoreCase("set")) { + sender.sendMessage(Component.text("Usage: /blackknife void get|set [player] | add [player]") + .color(NamedTextColor.YELLOW)); + return; + } + if (args.length < 3) { + sender.sendMessage(Component.text("Usage: /blackknife void " + args[1] + " [player]") + .color(NamedTextColor.YELLOW)); + return; + } + final int amount; + try { + amount = Integer.parseInt(args[2]); + } catch (NumberFormatException e) { + sender.sendMessage(Component.text("Not a number: " + args[2]).color(NamedTextColor.RED)); + return; + } + final Player target = args.length >= 4 ? BlackKnife.player(args[3]) + : (sender instanceof Player p ? p : null); + if (target == null) { + sender.sendMessage(Component.text("Player not found").color(NamedTextColor.RED)); + return; + } + if (add) { + VoidCounter.add(target, amount); + } else { + VoidCounter.set(target, amount); + } + VoidCounter.flush(); + sender.sendMessage(Component.text("Void of " + target.getName() + ": " + + VoidCounter.get(target) + " / " + VoidCounter.THRESHOLD) + .color(NamedTextColor.GREEN)); + } + private void usage(CommandSender sender) { sender.sendMessage(Component.text("Usage: /blackknife give [player] | mode [player] | tp [amount] | reload").color(NamedTextColor.YELLOW)); } @@ -91,6 +158,14 @@ public class BlackKnifeCommand implements CommandExecutor, TabCompleter { if (args.length == 1) return BlackKnife.filter(SUBS, args[0]); if (args[0].equalsIgnoreCase("give")) { if (args.length == 2) return BlackKnife.players(args[1]); + } else if (args[0].equalsIgnoreCase("void")) { + if (args.length == 2) return BlackKnife.filter(Arrays.asList("get", "set", "add"), args[1]); + if (args.length == 3 && (args[1].equalsIgnoreCase("set") || args[1].equalsIgnoreCase("add"))) { + return BlackKnife.filter(Arrays.asList("0", "19", "20", "100"), args[2]); + } + if (args.length == 4 && (args[1].equalsIgnoreCase("set") || args[1].equalsIgnoreCase("add"))) { + return BlackKnife.players(args[3]); + } } else if (args[0].equalsIgnoreCase("mode")) { if (args.length == 2) return BlackKnife.filter(Arrays.asList(BlackKnife.modes()), args[1]); if (args.length == 3) return BlackKnife.players(args[2]); diff --git a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java index 06b838a..f148848 100644 --- a/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java +++ b/src/main/java/me/sashegdev/blackknife/BlackKnifePlugin.java @@ -59,6 +59,10 @@ public class BlackKnifePlugin extends JavaPlugin { getCommand("magic").setExecutor(magicCmd); getCommand("magic").setTabCompleter(magicCmd); getServer().getScheduler().runTaskTimer(this, () -> tension.tickShield(), 20L, 1L); + // Счётчик пустоты живёт в файле: читаем при старте, пишем раз в минуту + // и при выключении. Запись асинхронная, потому что flush() ходит на диск. + VoidCounter.load(this); + getServer().getScheduler().runTaskTimerAsynchronously(this, VoidCounter::flush, 1200L, 1200L); cleanupLegacyKnocked(); getLogger().info("BlackKnife 1.0.0 enabled - Paper 1.21.8 lang=" + lang); } @@ -128,6 +132,9 @@ public class BlackKnifePlugin extends JavaPlugin { getServer().getScheduler().cancelTasks(this); StarFx.clear(); MoleHole.clearAll(); + // Счётчик сбрасываем на диск синхронно: после cancelTasks асинхронная + // задача уже не выполнится, а прогресс пустоты жалко терять. + VoidCounter.flush(); if (tension != null) { tension.hideAll(); for (var p : getServer().getOnlinePlayers()) { diff --git a/src/main/java/me/sashegdev/blackknife/MoleHole.java b/src/main/java/me/sashegdev/blackknife/MoleHole.java index e0ede12..e6acd6c 100644 --- a/src/main/java/me/sashegdev/blackknife/MoleHole.java +++ b/src/main/java/me/sashegdev/blackknife/MoleHole.java @@ -137,9 +137,18 @@ public final class MoleHole { s.exit = null; s.exitTo = null; s.inside.clear(); + s.owner = p.getUniqueId(); s.state = State.WAITING_COORDS; ensureTask(plugin); - p.sendActionBar(Component.text("Hole open. Type coordinates in chat: x, y, z").color(NamedTextColor.LIGHT_PURPLE)); + if (VoidCounter.unlocked(p)) { + // При открытом счётчике дыра работает и без ответа: вход в пустоту. + p.sendActionBar(Component.text("Hole open. Type coordinates, or step in for the void") + .color(NamedTextColor.LIGHT_PURPLE)); + } else { + final int left = VoidCounter.remaining(p); + p.sendActionBar(Component.text("Hole open. Type coordinates in chat: x, y, z (" + + left + " more for the void)").color(NamedTextColor.LIGHT_PURPLE)); + } return true; } @@ -296,7 +305,26 @@ public final class MoleHole { // Погружение тех, кто встал в дыру. for (Session s : SESSIONS.values()) { - if (s.state != State.PAIRED || s.entrance == null || s.exit == null) continue; + if (s.entrance == null) continue; + + // Одиночная нора: ответа нет, поэтому обычный телепорт некуда. + // При открытом счётчике пустоты вход в такую дыру уводит игрока в + // пустоту — и только его: правило «дыра без ответа пускает лишь + // открывшего» иначе превратило бы счётчик в общий телепорт для всех. + if (s.state == State.WAITING_COORDS) { + if (s.owner == null || !VoidCounter.unlockedBy(s.owner)) continue; + final Player owner = Bukkit.getPlayer(s.owner); + if (owner == null || owner.isDead() || !standingIn(owner, s.entrance)) continue; + if (s.inside.getOrDefault(s.owner, Boolean.FALSE)) continue; + s.inside.put(s.owner, Boolean.TRUE); + MoleHoleEffect.sink(plugin, owner, s.entrance, who -> { + VoidWorld.forget(who); + VoidWorld.enter(who); + }); + continue; + } + + if (s.state != State.PAIRED || s.exit == null) continue; for (Player target : Bukkit.getOnlinePlayers()) { final UUID uid = target.getUniqueId(); diff --git a/src/main/java/me/sashegdev/blackknife/VoidCounter.java b/src/main/java/me/sashegdev/blackknife/VoidCounter.java new file mode 100644 index 0000000..f641a97 --- /dev/null +++ b/src/main/java/me/sashegdev/blackknife/VoidCounter.java @@ -0,0 +1,161 @@ +package me.sashegdev.blackknife; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Счётчик «пустоты» игрока: сколько пар нор он открыл. + * + *

Хранится в файле, а не в PDC, по условию. PDC живут в папке игрока и теряются + * при её подмене, плюс их не видно без разбора NBT. Тут обычный YAML, который + * можно открыть и прочитать глазами. + * + *

Порог 20 — с него открывается вход в пустоту без ответной дыры. + * + *

Запись на диск идёт через временный файл и подмену: если сервер убьют в + * момент записи, останется предыдущая целая версия, а не обрезанный файл. + * Сохранение вызывается из асинхронной задачи, поэтому доступ к карте + * синхронизирован. + */ +public final class VoidCounter { + + private VoidCounter() { + } + + /** Сколько пар нужно, чтобы открылся вход в пустоту. */ + public static final int THRESHOLD = 20; + + private static final Object LOCK = new Object(); + private static final Map COUNTS = new HashMap<>(); + private static File file; + private static boolean dirty; + + /** Загружает счётчики из файла. Вызывается один раз при старте. */ + public static void load(BlackKnifePlugin plugin) { + file = new File(plugin.getDataFolder(), "void.yml"); + synchronized (LOCK) { + COUNTS.clear(); + if (!file.exists()) { + return; + } + final YamlConfiguration y = YamlConfiguration.loadConfiguration(file); + for (String key : y.getKeys(false)) { + // Ключ в YAML — UUID со знаками минус, точки в нём нет, так что + // секции достаточно: путь ровно players.. + final var section = y.getConfigurationSection("players." + key); + if (section == null) continue; + final int n = section.getInt("count", 0); + if (n > 0) { + try { + COUNTS.put(UUID.fromString(key), n); + } catch (IllegalArgumentException ignored) { + // Мусор в файле не должен ронять загрузку. + } + } + } + } + } + + public static int get(UUID id) { + synchronized (LOCK) { + return COUNTS.getOrDefault(id, 0); + } + } + + public static int get(Player p) { + return get(p.getUniqueId()); + } + + /** Ставит счётчик в точное значение — для настройки и проверки порога. */ + public static void set(Player p, int value) { + synchronized (LOCK) { + final int next = Math.max(0, value); + if (next == 0) { + COUNTS.remove(p.getUniqueId()); + } else { + COUNTS.put(p.getUniqueId(), next); + } + dirty = true; + } + } + + /** Хватает ли порога на пустоту. */ + public static boolean unlocked(Player p) { + return get(p) >= THRESHOLD; + } + + /** То же по UUID: нужно там, где игрока ещё нет под рукой. */ + public static boolean unlockedBy(UUID id) { + return get(id) >= THRESHOLD; + } + + /** Плюс один за открытую пару. Помечает файл грязным, запись — отложенная. */ + public static void addPair(Player p) { + add(p, 1); + } + + public static void add(Player p, int delta) { + synchronized (LOCK) { + final UUID id = p.getUniqueId(); + final int next = Math.max(0, COUNTS.getOrDefault(id, 0) + delta); + COUNTS.put(id, next); + dirty = true; + } + } + + /** Сколько осталось до порога. Для сообщений. */ + public static int remaining(Player p) { + return Math.max(0, THRESHOLD - get(p)); + } + + public static void clear(Player p) { + synchronized (LOCK) { + if (COUNTS.remove(p.getUniqueId()) != null) { + dirty = true; + } + } + } + + /** Пишет на диск, если есть что писать. Вызывать из асинхронной задачи. */ + public static void flush() { + final File target; + synchronized (LOCK) { + if (file == null || !dirty) { + return; + } + dirty = false; + target = file; + } + final YamlConfiguration y = new YamlConfiguration(); + synchronized (LOCK) { + for (Map.Entry e : COUNTS.entrySet()) { + y.set("players." + e.getKey() + ".count", e.getValue()); + } + } + try { + final File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + return; + } + final File tmp = new File(target.getParentFile(), target.getName() + ".tmp"); + Files.write(tmp.toPath(), y.saveToString().getBytes(StandardCharsets.UTF_8)); + Files.move(tmp.toPath(), target.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + synchronized (LOCK) { + dirty = true; // попробуем ещё раз в следующий раз + } + BlackKnifePlugin.getInstance().getLogger().warning( + "void.yml not written: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/me/sashegdev/blackknife/VoidWorld.java b/src/main/java/me/sashegdev/blackknife/VoidWorld.java index a5521b7..da6c119 100644 --- a/src/main/java/me/sashegdev/blackknife/VoidWorld.java +++ b/src/main/java/me/sashegdev/blackknife/VoidWorld.java @@ -56,6 +56,12 @@ public final class VoidWorld implements Listener { /** Режим до входа в пустоту, чтобы вернуть как было, а не угадать. */ private static final Map PREVIOUS_MODE = new HashMap<>(); + /** Откуда вошли — понадобится для выхода обратно в своём соотношении. */ + private static final Map ENTRY_POINT = new HashMap<>(); + + /** Сколько секунд игрок пробыл в пустоте в этой сессии. Обнуляется на выходе. */ + private static final Map VOIDNESS = new HashMap<>(); + private static final long AMBIENCE_PERIOD = 60L; /** Это мир пустоты? Смотрим на имя измерения, а не на флаги уровня. */ @@ -85,6 +91,74 @@ public final class VoidWorld implements Listener { return detectedWorldName; } + /** Мир пустоты или null, если он не найден. */ + public static World world() { + for (World w : org.bukkit.Bukkit.getWorlds()) { + if (isVoid(w)) { + return w; + } + } + return null; + } + + /** Сколько секунд игрок пробыл в пустоте в этой сессии. */ + public static int voidness(Player p) { + return VOIDNESS.getOrDefault(p.getUniqueId(), 0); + } + + /** Сброс при выходе из пустоты: счётчик сессионный, рестарт его тоже убирает. */ + public static int takeVoidness(Player p) { + return VOIDNESS.remove(p.getUniqueId()) != null ? voidness(p) : 0; + } + + /** + * Переносит игрока в пустоту. + * + *

Точка приземления — верхний блок в колонке 0,0 плюс блок сверху. Не + * угадываем, на какой высоте лягут слои барьера: плоский генератор ставит их + * от низа диапазона, но конкретные числа зависят от версии, а + * getHighestBlockYAt находит верх всегда. + * + *

Куда вошли — запоминаем, чтобы потом вернуть игрока в ту же точку + * обычного мира. Обратный путь использует соотношение 1:2, поэтому точка + * выхода не равна точке входа по координатам. + * + * @return true, если перенос состоялся + */ + public static boolean enter(Player p) { + final World vw = world(); + if (vw == null) { + p.sendActionBar(Component.text("The void is not ready") + .color(NamedTextColor.RED)); + return false; + } + ENTRY_POINT.put(p.getUniqueId(), p.getLocation().clone()); + VOIDNESS.put(p.getUniqueId(), 0); + + final int top = vw.getHighestBlockYAt(0, 0); + final Location dest = new Location(vw, 0.5D, top + 1.0D, 0.5D); + p.teleport(dest); + p.setFallDistance(0f); + p.setVelocity(new org.bukkit.util.Vector(0, 0, 0)); + // Событие смены мира само поставит приключения, но только если игрок + // перешёл границу. Сюда он может попасть и без перехода (из мёртвого + // состояния), поэтому режим дублируем на случай отсутствия события. + p.setGameMode(GameMode.ADVENTURE); + p.sendActionBar(Component.text("Nothing here keeps you").color(NamedTextColor.DARK_GRAY)); + return true; + } + + /** Откуда игрок вошёл. null, если не входил. */ + public static Location entryPoint(Player p) { + return ENTRY_POINT.get(p.getUniqueId()); + } + + public static void forget(Player p) { + PREVIOUS_MODE.remove(p.getUniqueId()); + ENTRY_POINT.remove(p.getUniqueId()); + VOIDNESS.remove(p.getUniqueId()); + } + // -------------------------------------------------------------- переходы ---- @EventHandler @@ -113,7 +187,7 @@ public final class VoidWorld implements Listener { @EventHandler public void onQuit(PlayerQuitEvent e) { - PREVIOUS_MODE.remove(e.getPlayer().getUniqueId()); + forget(e.getPlayer()); } // ------------------------------------------------------------- запреты ---- @@ -192,6 +266,9 @@ public final class VoidWorld implements Listener { @Override public void run() { for (Player p : plugin.getServer().getOnlinePlayers()) { if (!isVoid(p.getWorld())) continue; + // Раз в секунду тикает voidness: условие «за каждую секунду + // в этом мире +1», а период задачи ровно секунда. + VOIDNESS.merge(p.getUniqueId(), 1, Integer::sum); final Location eye = p.getEyeLocation(); // Звук не каждый тик, иначе это не эмбиент, а шум.