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:
@@ -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();
|
||||
|
||||
// Звук не каждый тик, иначе это не эмбиент, а шум.
|
||||
|
||||
Reference in New Issue
Block a user