feat(void): persisted void counter and solo entry into the void world

Девятый этап и вход в пустоту: счётчик в файле плюс возможность войти в одиночку.

Счётчик 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. Сервер добавляет
префикс папки мира и санитизирует путь, так что угадать его в коде было бы
верным способом не заработать. Проверка по имени мира, а не по флагам уровня.

Проверено на сервере: команда отвечает, измерение находится, генератор кладёт
барьерные слои на нескольких высотах. Сам перенос игрока не проверялся —
нужен живой игрок.
This commit is contained in:
SashegDev
2026-10-17 15:52:28 +00:00
parent ecb741140b
commit 5a1ea2343e
5 changed files with 352 additions and 4 deletions
@@ -13,7 +13,7 @@ import java.util.Collections;
import java.util.List;
public class BlackKnifeCommand implements CommandExecutor, TabCompleter {
private static final List<String> SUBS = Arrays.asList("give", "reload", "mode", "tp");
private static final List<String> SUBS = Arrays.asList("give", "reload", "mode", "tp", "void");
private static final List<String> 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 <get|set|add> <player> [amount]").color(NamedTextColor.YELLOW));
return true;
}
if (args[0].equalsIgnoreCase("void")) {
voidCounter(sender, args);
return true;
}
usage(sender);
return true;
}
/**
* Счётчик пустоты: сколько пар нор открыл игрок.
*
* <p>Нужен и для настройки баланса, и для проверки входа в пустоту: порог 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 <amount> [player] | add <n> [player]")
.color(NamedTextColor.YELLOW));
return;
}
if (args.length < 3) {
sender.sendMessage(Component.text("Usage: /blackknife void " + args[1] + " <amount> [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 <mode> [player] | tp <get|set|add> <player> [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]);
@@ -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()) {
@@ -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();
@@ -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;
/**
* Счётчик «пустоты» игрока: сколько пар нор он открыл.
*
* <p>Хранится в файле, а не в PDC, по условию. PDC живут в папке игрока и теряются
* при её подмене, плюс их не видно без разбора NBT. Тут обычный YAML, который
* можно открыть и прочитать глазами.
*
* <p>Порог 20 — с него открывается вход в пустоту без ответной дыры.
*
* <p>Запись на диск идёт через временный файл и подмену: если сервер убьют в
* момент записи, останется предыдущая целая версия, а не обрезанный файл.
* Сохранение вызывается из асинхронной задачи, поэтому доступ к карте
* синхронизирован.
*/
public final class VoidCounter {
private VoidCounter() {
}
/** Сколько пар нужно, чтобы открылся вход в пустоту. */
public static final int THRESHOLD = 20;
private static final Object LOCK = new Object();
private static final Map<UUID, Integer> 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.<uuid>.
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<UUID, Integer> 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());
}
}
}
@@ -56,6 +56,12 @@ public final class VoidWorld implements Listener {
/** Режим до входа в пустоту, чтобы вернуть как было, а не угадать. */
private static final Map<UUID, GameMode> PREVIOUS_MODE = new HashMap<>();
/** Откуда вошли — понадобится для выхода обратно в своём соотношении. */
private static final Map<UUID, Location> ENTRY_POINT = new HashMap<>();
/** Сколько секунд игрок пробыл в пустоте в этой сессии. Обнуляется на выходе. */
private static final Map<UUID, Integer> 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;
}
/**
* Переносит игрока в пустоту.
*
* <p>Точка приземления — верхний блок в колонке 0,0 плюс блок сверху. Не
* угадываем, на какой высоте лягут слои барьера: плоский генератор ставит их
* от низа диапазона, но конкретные числа зависят от версии, а
* getHighestBlockYAt находит верх всегда.
*
* <p>Куда вошли — запоминаем, чтобы потом вернуть игрока в ту же точку
* обычного мира. Обратный путь использует соотношение 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();
// Звук не каждый тик, иначе это не эмбиент, а шум.