From 978f739045cd2102a7d31b035667c23317a57aea Mon Sep 17 00:00:00 2001 From: loki Date: Tue, 17 Mar 2026 13:36:23 +0100 Subject: [PATCH] fix jump slab --- release/GUIDE.md | 230 ++++++ .../java/dev/loki/loparkour/LoParkour.java | 269 +++++++ .../loparkour/generator/jump/BlockPlacer.java | 281 +++++++ .../generator/jump/JumpOffsetGenerator.java | 78 ++ .../lifecycle/GeneratorLifecycle.java | 265 +++++++ .../dev/loki/loparkour/ghost/GhostData.java | 102 +++ .../loki/loparkour/ghost/GhostManager.java | 162 ++++ .../dev/loki/loparkour/ghost/GhostPlayer.java | 78 ++ .../loki/loparkour/ghost/GhostRecorder.java | 61 ++ .../dev/loki/loparkour/mode/CoopMode.java | 192 +++++ .../loki/loparkour/mode/ElytraGenerator.java | 750 ++++++++++++++++++ .../dev/loki/loparkour/mode/ElytraMode.java | 55 ++ .../dev/loki/loparkour/mode/RaceMode.java | 150 ++++ .../dev/loki/loparkour/mode/SpeedrunMode.java | 154 ++++ .../storage/SQLMigrationManager.java | 50 ++ .../loparkour/storage/SQLQueryExecutor.java | 86 ++ .../loki/loparkour/storage/StorageSQL.java | 246 ++++++ release/src/main/resources/config.yml | 524 ++++++++++++ release/src/main/resources/locales/en.yml | 295 +++++++ .../java/dev/loki/loparkour/LoParkour.java | 96 +-- .../command/PlayerCommandHandler.java | 2 +- .../dev/loki/loparkour/config/Locales.java | 14 +- .../loparkour/generator/jump/BlockPlacer.java | 79 +- .../generator/jump/JumpOffsetGenerator.java | 4 +- .../generator/jump/JumpValidator.java | 87 +- .../lifecycle/GeneratorLifecycle.java | 31 +- .../dev/loki/loparkour/ghost/GhostData.java | 95 ++- .../loki/loparkour/ghost/GhostManager.java | 138 ++-- .../dev/loki/loparkour/ghost/GhostPlayer.java | 62 +- .../loki/loparkour/ghost/GhostRecorder.java | 46 +- .../dev/loki/loparkour/mode/CoopMode.java | 192 +++-- .../loki/loparkour/mode/ElytraGenerator.java | 736 ++++++++++++++++- .../dev/loki/loparkour/mode/ElytraMode.java | 190 +---- .../dev/loki/loparkour/mode/RaceMode.java | 114 ++- .../dev/loki/loparkour/mode/SpeedrunMode.java | 107 ++- .../storage/SQLMigrationManager.java | 22 +- .../loparkour/storage/SQLQueryExecutor.java | 59 +- .../loki/loparkour/storage/StorageSQL.java | 214 +++-- src/main/resources/config.yml | 36 +- src/main/resources/locales/en.yml | 31 +- src/main/resources/locales/ru.yml | 96 +-- .../generator/jump/JumpValidatorTest.java | 12 + .../loki/loparkour/ghost/GhostDataTest.java | 26 +- 43 files changed, 5685 insertions(+), 832 deletions(-) create mode 100644 release/GUIDE.md create mode 100644 release/src/main/java/dev/loki/loparkour/LoParkour.java create mode 100644 release/src/main/java/dev/loki/loparkour/generator/jump/BlockPlacer.java create mode 100644 release/src/main/java/dev/loki/loparkour/generator/jump/JumpOffsetGenerator.java create mode 100644 release/src/main/java/dev/loki/loparkour/generator/lifecycle/GeneratorLifecycle.java create mode 100644 release/src/main/java/dev/loki/loparkour/ghost/GhostData.java create mode 100644 release/src/main/java/dev/loki/loparkour/ghost/GhostManager.java create mode 100644 release/src/main/java/dev/loki/loparkour/ghost/GhostPlayer.java create mode 100644 release/src/main/java/dev/loki/loparkour/ghost/GhostRecorder.java create mode 100644 release/src/main/java/dev/loki/loparkour/mode/CoopMode.java create mode 100644 release/src/main/java/dev/loki/loparkour/mode/ElytraGenerator.java create mode 100644 release/src/main/java/dev/loki/loparkour/mode/ElytraMode.java create mode 100644 release/src/main/java/dev/loki/loparkour/mode/RaceMode.java create mode 100644 release/src/main/java/dev/loki/loparkour/mode/SpeedrunMode.java create mode 100644 release/src/main/java/dev/loki/loparkour/storage/SQLMigrationManager.java create mode 100644 release/src/main/java/dev/loki/loparkour/storage/SQLQueryExecutor.java create mode 100644 release/src/main/java/dev/loki/loparkour/storage/StorageSQL.java create mode 100644 release/src/main/resources/config.yml create mode 100644 release/src/main/resources/locales/en.yml diff --git a/release/GUIDE.md b/release/GUIDE.md new file mode 100644 index 0000000..94e59c6 --- /dev/null +++ b/release/GUIDE.md @@ -0,0 +1,230 @@ +# LoParkour — Гайд по патчам + +Все файлы в этом архиве заменяют оригинальные файлы проекта **1 к 1 по пути**. +Просто скопируй их поверх своего src/ и пересобери проект. + +--- + +## Что уже исправлено (этот архив) + +### 🔴 Патч 1 — SQL Injection (КРИТИЧНО) +**Файлы:** `storage/StorageSQL.java`, `storage/SQLQueryExecutor.java`, `storage/SQLMigrationManager.java` + +Оригинальный код вставлял имена игроков, стили, локали напрямую в SQL строки через `.formatted()`. +Игрок с ником `'; DROP TABLE loparkour-leaderboard-default; --` мог уничтожить таблицу лидерборда. + +**Что сделано:** Все запросы с пользовательскими данными переписаны на `?` плейсхолдеры. +JDBC-драйвер сам экранирует значения — инъекция физически невозможна. + +--- + +### 🔴 Патч 2 — ElytraMode полная переработка +**Файлы:** `mode/ElytraMode.java`, `mode/ElytraGenerator.java`, `resources/config.yml`, `resources/locales/en.yml` + +Оригинальный ElytraMode был просто обычным паркуром с элитрой в инвентаре. +Кольца лежали горизонтально (как блины), хитбокс был кубом 5×5×5, +cooldown фейерверка не работал (показывал сообщение но не блокировал). + +**Что сделано:** +- Генерируются кольца в воздухе по плавной кривой (настраивается в config.yml) +- Кольца стоят вертикально, перпендикулярно направлению полёта +- Детект пролёта через плоскость кольца (dot product, а не куб) +- Firework перехватывается через `PlayerInteractEvent` — реально блокируется до использования +- Boost выдаётся вручную через `setVelocity()` + визуальный фейерверк без урона +- Action bar: счёт + дистанция до следующего кольца + cooldown + +--- + +### 🟠 Патч 3 — SpeedrunMode не работал вообще +**Файл:** `mode/SpeedrunMode.java` + +`onBlockTouch()` был объявлен но **нигде не вызывался** — блоки никогда не исчезали. +Speedrun был идентичен обычному режиму. + +**Что сделано:** Подключился через `score()` override — вызывается в момент когда +игрок встаёт на новый блок. Исправлено деление на 50 которое давало 0 тиков при +малых значениях. Warning task теперь отменяется при reset(). + +--- + +### 🟠 Патч 4 — CoopMode показывался в одиночном меню +**Файл:** `mode/CoopMode.java` + +`CoopMode implements Mode` вместо `MultiMode` — поэтому висел в меню рядом с Default/Speedrun. +`SingleMenu` фильтрует `instanceof MultiMode`, но кооп им не был. + +**Что сделано:** `CoopMode implements MultiMode`. `getItem()` возвращает `null`. +`create()` открывает лобби вместо создания сессии соло. +Исправлен двойной счётчик (`sharedScore` дублировал `state.score`). +`fall()` больше не чистит стейт который уже сброшен внутри `super.fall()`. + +--- + +### 🟠 Патч 5 — RaceMode off-by-one + ActionBar конфликт +**Файл:** `mode/RaceMode.java` + +Проверка финиша была в `tick()` — срабатывала на тик позже нужного. +ActionBar обновлялся каждый тик конфликтуя с parent. +`reset(false)` вызывался с задержкой без null-check — NPE если игрок вышел. + +**Что сделано:** Финиш проверяется в `score()` override — точно в момент достижения цели. +Прогресс-бар обновляется только при новом очке. Добавлен guard `!state.stopped`. + +--- + +## Что ещё нужно исправить (следующие шаги) + +### 🔴 Приоритет 1 — Ghost файлы по имени игрока (path traversal + коллизии) +**Файл:** `ghost/GhostManager.java` + +```java +// ПРОБЛЕМА: +File file = new File(modeFolder, data.getPlayerName() + ".ghost"); +``` + +Два игрока с одинаковым именем затирают друг друга. +Игрок с именем `../../config` может писать за пределы папки плагина. + +**Как чинить:** Называть файлы по UUID игрока, не по имени. +Хранить имя внутри файла (оно уже там есть в `GhostData`). + +--- + +### 🟡 Приоритет 2 — Ghost не пишет всех игроков в мультиплеере +**Файл:** `generator/lifecycle/GeneratorLifecycle.java` + +`GhostRecorder` записывает только `generator.player` (первый игрок сессии). +В CoopMode и RaceMode остальные игроки не записываются. + +**Как чинить:** Recorder на каждого игрока отдельно, или писать только в single-player режимах. + +--- + +### 🟡 Приоритет 3 — `disable()` глотает все исключения +**Файл:** `LoParkour.java` + +```java +} catch (Throwable ignored) { } +``` + +Если при выключении сервера не сохранятся данные — никто не узнает. + +**Как чинить:** Минимум логировать: `catch (Throwable t) { getLogger().severe(...) }`. + +--- + +### 🟡 Приоритет 4 — `cleanupDistantBlocks()` вызывается дважды за тик +**Файл:** `generator/lifecycle/GeneratorLifecycle.java` + +Вызывается явно в `tick()` И через отдельный `cleanupTask` таймер одновременно. + +**Как чинить:** Убрать явный вызов из `tick()`, оставить только таймер. + +--- + +### 🟡 Приоритет 5 — `new Random()` в горячих методах +**Файл:** `generator/jump/BlockPlacer.java` + +```java +pool.get(new java.util.Random().nextInt(pool.size())) // внутри tryGenerateSchematic() +new java.util.Random().nextInt(rotations.length) // внутри rotatedPaste() +``` + +Каждый вызов создаёт новый объект Random. Дорого и неправильно. + +**Как чинить:** Один `private static final ThreadLocalRandom RNG = ThreadLocalRandom.current()` +на уровне класса. + +--- + +### 🟡 Приоритет 6 — `System.err.println` в JumpOffsetGenerator +**Файл:** `generator/jump/JumpOffsetGenerator.java` + +```java +System.err.println("Warning: Unexpected jump height ..."); +``` + +В Bukkit плагинах нельзя писать в System.err напрямую. + +**Как чинить:** `LoParkour.getPlugin().getLogger().warning(...)`. + +--- + +### 🟢 Приоритет 7 — Пустой javadoc в LoParkour.java +**Файл:** `LoParkour.java` + +Висит осиротевший javadoc от удалённого метода `getLogging()`. Не баг, просто мусор. + +--- + +## Порядок следующих патчей (рекомендация) + +1. Ghost path traversal (безопасность) +2. `disable()` silent catch (надёжность) +3. `cleanupDistantBlocks` двойной вызов (производительность) +4. `new Random()` в горячих путях (производительность) +5. Ghost мультиплеер (функциональность) + +--- + +## Патчи добавленные в эту версию архива + +### 🔴 Патч 6 — Ghost path traversal + коллизии по имени (БЕЗОПАСНОСТЬ) +**Файлы:** `ghost/GhostData.java`, `ghost/GhostManager.java`, `ghost/GhostRecorder.java`, `generator/lifecycle/GeneratorLifecycle.java` + +Файлы назывались `playerName.ghost`. Два игрока с одинаковым ником затирали друг друга. +Игрок с именем `../../config` мог писать файлы за пределы папки плагина (path traversal). + +**Что сделано:** +- Файлы теперь называются `uuid.ghost` — уникально и filesystem-safe +- `GhostData` хранит UUID (добавлен в конструктор и в бинарный формат) +- `GhostRecorder.stopRecording()` принимает UUID + имя +- `GhostManager.sanitizeMode()` чистит имя режима от слэшей +- Старые файлы v1 (без UUID) автоматически удаляются при загрузке с логом +- Формат файла версионирован (int VERSION=2 в начале) + +--- + +### 🟡 Патч 7 — `cleanupDistantBlocks()` вызывался дважды за тик +**Файл:** `generator/lifecycle/GeneratorLifecycle.java` + +Был явный вызов в `tick()` И таймер `cleanupTask` — двойная работа каждый тик. + +**Что сделано:** Убран явный вызов из `tick()`. Только таймер. + +--- + +### 🟡 Патч 8 — `new Random()` в горячих методах BlockPlacer +**Файл:** `generator/jump/BlockPlacer.java` + +`new java.util.Random()` создавался внутри `tryGenerateSchematic()` и `rotatedPaste()` при каждом вызове. + +**Что сделано:** Заменено на `ThreadLocalRandom.current().nextInt()`. + +--- + +### 🟡 Патч 9 — `System.err.println` в JumpOffsetGenerator +**Файл:** `generator/jump/JumpOffsetGenerator.java` + +**Что сделано:** Заменено на `LoParkour.getPlugin().getLogger().warning(...)`. + +--- + +### 🟡 Патч 10 — `disable()` глотал все исключения молча +**Файл:** `LoParkour.java` + +`catch (Throwable ignored) {}` — если данные не сохранялись при выключении сервера, никто не узнавал. + +**Что сделано:** `catch (Throwable t)` с логированием и `t.printStackTrace()`. + +--- + +## Оставшиеся задачи + +### 🟡 Ghost мультиплеер +`GhostRecorder` пишет только `generator.player` (первый игрок сессии). +В CoopMode/RaceMode остальные игроки не записываются. +**Решение:** Либо отдельный рекордер на каждого, либо запись только в single-player режимах. + +### 🟢 Осиротевший javadoc в LoParkour.java +Комментарий от удалённого метода `getLogging()` — просто мусор, не баг. diff --git a/release/src/main/java/dev/loki/loparkour/LoParkour.java b/release/src/main/java/dev/loki/loparkour/LoParkour.java new file mode 100644 index 0000000..27a000d --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/LoParkour.java @@ -0,0 +1,269 @@ +package dev.loki.loparkour; + +import dev.loki.loparkour.api.Registry; +import dev.loki.loparkour.config.Config; +import dev.loki.loparkour.config.Locales; +import dev.loki.loparkour.config.Option; +import dev.loki.loparkour.hook.HoloHook; +import dev.loki.loparkour.hook.PAPIHook; +import dev.loki.loparkour.mode.DefaultMode; +import dev.loki.loparkour.mode.ElytraMode; +import dev.loki.loparkour.mode.Modes; +import dev.loki.loparkour.mode.SpectatorMode; +import dev.loki.loparkour.mode.SpeedrunMode; +import dev.loki.loparkour.mode.RaceMode; +import dev.loki.loparkour.mode.CoopMode; +import dev.loki.loparkour.player.ParkourUser; +import dev.loki.loparkour.player.UserRegistry; +import dev.loki.loparkour.reward.Rewards; +import dev.loki.loparkour.schematic.lpschem.LPSchematicManager; +import dev.loki.loparkour.storage.Storage; +import dev.loki.loparkour.world.World; +import dev.lolib.core.LoPlugin; +import dev.lolib.scheduler.Scheduler; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.bstats.bukkit.Metrics; +import org.bstats.charts.SimplePie; +import org.bstats.charts.SingleLineChart; + +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Main class of LoParkour + * + * @author loki + */ +public final class LoParkour extends LoPlugin { + + public static final String NAME = "&#FF6464LoParkour"; + public static final String PREFIX = NAME + " 񢩈» &#A0A0A0"; + + private static final Gson gson = new GsonBuilder() + .setPrettyPrinting() + .excludeFieldsWithoutExposeAnnotation() + .create(); + + public static Gson getGson() { + return gson; + } + private static LoParkour instance; + private static LPSchematicManager schematicManager; + + @Nullable + private static PAPIHook placeholderHook; + + public static void log(String message) { + if (Config.CONFIG.getBoolean("debug")) { + LoParkour.getPlugin().getLogger().info("[Debug] " + message); + } + } + + /** + * @param child The file name. + * @return A file from within the plugin folder. + */ + public static File getInFolder(String child) { + return new File(instance.getDataFolder(), child); + } + + /** + * @return This plugin's {@link Logging} instance. + */ + + + /** + * @return The plugin instance. + */ + public static LoParkour getPlugin() { + return instance; + } + + @Nullable + public static PAPIHook getPlaceholderHook() { + return placeholderHook; + } + + public static LPSchematicManager getSchematicManager() { + return schematicManager; + } + + + + @Override + public void enable() { + instance = this; // must be first — Config enum accesses instance inside reload() + loadConfigs(); + loadSchematics(); + registerModes(); + registerHooks(); + setupWorld(); + registerEventsAndCommands(); + setupMetrics(); + } + + private void loadConfigs() { + Config.reload(true); + } + + private void loadSchematics() { + schematicManager = new LPSchematicManager(); + + // Create default island schematic if it doesn't exist + File schematicsFolder = getInFolder("schematics-new"); + File islandFile = new File(schematicsFolder, "island.lpschem"); + + if (!islandFile.exists()) { + try { + createDefaultIslandSchematic(islandFile); + getLogger().info("Created default island schematic"); + } catch (Exception ex) { + getLogger().severe("Failed to create default island schematic: " + ex.getMessage()); + ex.printStackTrace(); + } + } + + schematicManager.loadAll(); + } + + private void createDefaultIslandSchematic(File file) throws Exception { + // Create a larger 9x5x9 island for better visibility and safety + List palette = new ArrayList<>(); + palette.add("minecraft:air"); + palette.add("minecraft:stone"); + palette.add("minecraft:diamond_block"); + palette.add("minecraft:emerald_block"); + + int width = 9, height = 5, length = 9; + int[] blocks = new int[width * height * length]; + + // Fill everything with air first + for (int i = 0; i < blocks.length; i++) { + blocks[i] = 0; + } + + // Create a solid stone platform at y=1 (7x7 platform) + for (int z = 1; z < 8; z++) { + for (int x = 1; x < 8; x++) { + int index = x + (z * width) + (1 * width * length); + blocks[index] = 1; // stone + } + } + + // Place diamond block (player spawn) at center + int centerX = 4, centerZ = 4; + int diamondIndex = centerX + (centerZ * width) + (1 * width * length); + blocks[diamondIndex] = 2; // diamond_block - player spawns here + + // Place emerald block (parkour start) to the right of spawn + int emeraldIndex = (centerX + 1) + (centerZ * width) + (1 * width * length); + blocks[emeraldIndex] = 3; // emerald_block - parkour starts here + + dev.loki.loparkour.schematic.lpschem.SchematicMetadata metadata = + new dev.loki.loparkour.schematic.lpschem.SchematicMetadata("island", "LoParkour", 0.0); + metadata.addTag("spawn"); + metadata.addTag("island"); + + dev.loki.loparkour.schematic.lpschem.SchematicDimensions dimensions = + new dev.loki.loparkour.schematic.lpschem.SchematicDimensions(width, height, length); + + // Markers point to the blocks where player spawns and parkour begins + dev.loki.loparkour.schematic.lpschem.SchematicMarkers markers = + new dev.loki.loparkour.schematic.lpschem.SchematicMarkers( + new dev.loki.loparkour.schematic.lpschem.SchematicMarkers.Vector3i(centerX, 1, centerZ), + new dev.loki.loparkour.schematic.lpschem.SchematicMarkers.Vector3i(centerX + 1, 1, centerZ) + ); + + dev.loki.loparkour.schematic.lpschem.LPSchematic schematic = + new dev.loki.loparkour.schematic.lpschem.LPSchematic(metadata, dimensions, palette, blocks, markers); + + schematic.save(file); + } + + private void registerModes() { + Registry.register(new DefaultMode()); + Registry.register(new SpectatorMode()); + Registry.register(new SpeedrunMode()); + Registry.register(new ElytraMode()); + Registry.register(new RaceMode()); + Registry.register(new CoopMode()); + Modes.init(); + } + + private void registerHooks() { + if (getServer().getPluginManager().isPluginEnabled("HolographicDisplays")) { + getLogger().info("Registered Holographic Displays hook"); + HoloHook.init(); + } + if (getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) { + getLogger().info("Registered PlaceholderAPI hook"); + placeholderHook = new PAPIHook(); + placeholderHook.register(); + } + if (Config.CONFIG.getBoolean("bungeecord.enabled")) { + getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord"); + getLogger().info("Registered BungeeCord hook"); + } + } + + private void setupWorld() { + if (!Config.CONFIG.getBoolean("joining")) { + return; + } + + // World creation MUST happen on the main thread (Bukkit API requirement) + try { + loLogger().info("Creating parkour world..."); + World.create(); + loLogger().info("Parkour world created successfully!"); + } catch (Exception ex) { + loLogger().error("Failed to create parkour world", ex); + } + } + + private void registerEventsAndCommands() { + dev.lolib.gui.GUIManager.init(this); // required for LoLib GUI click handling + + var pm = getServer().getPluginManager(); + pm.registerEvents(new dev.loki.loparkour.listener.PlayerConnectionListener(), this); + pm.registerEvents(new dev.loki.loparkour.listener.ParkourRestrictionListener(), this); + pm.registerEvents(new dev.loki.loparkour.listener.SchematicWandListener(), this); + + LoParkourCommand cmd = new LoParkourCommand(); + getCommand("LoParkour").setExecutor(cmd); + getCommand("LoParkour").setTabCompleter(cmd); + } + + private void setupMetrics() { + Metrics metrics = new Metrics(this, 29754); + metrics.addCustomChart(new SimplePie("using_sql", () -> Boolean.toString(Option.SQL))); + metrics.addCustomChart(new SimplePie("using_rewards", () -> Boolean.toString(Rewards.REWARDS_ENABLED))); + metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.locales.size()))); + metrics.addCustomChart(new SingleLineChart("player_joins", () -> { + int joins = UserRegistry.getJoinCount(); + return joins; + })); + } + + @Override + public void disable() { + try { + for (ParkourUser user : ParkourUser.getUsers()) { + ParkourUser.leave(user); + } + + // write all LoParkour gamemodes + Modes.DEFAULT.getLeaderboard().write(false); + + Storage.close(); + World.delete(); + } catch (Throwable t) { + getLogger().severe("Error during plugin shutdown: " + t.getMessage()); + t.printStackTrace(); + } + } +} diff --git a/release/src/main/java/dev/loki/loparkour/generator/jump/BlockPlacer.java b/release/src/main/java/dev/loki/loparkour/generator/jump/BlockPlacer.java new file mode 100644 index 0000000..8227ebf --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/generator/jump/BlockPlacer.java @@ -0,0 +1,281 @@ +package dev.loki.loparkour.generator.jump; + +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.api.event.ParkourBlockGenerateEvent; +import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent; +import dev.loki.loparkour.api.Registry; +import dev.loki.loparkour.config.Config; +import dev.loki.loparkour.config.Option; +import dev.loki.loparkour.generator.GeneratorOption; +import dev.loki.loparkour.generator.ParkourGenerator; +import dev.loki.loparkour.style.Style; +import dev.loki.loparkour.util.Probs; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.Fence; +import org.bukkit.block.data.type.GlassPane; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Level; + +/** + * Handles block selection, placement, and schematic generation. + * Extracted from {@link ParkourGenerator}. + */ +public class BlockPlacer { + + private final ParkourGenerator g; + + public BlockPlacer(ParkourGenerator generator) { + this.g = generator; + } + + // ── Public entry points ──────────────────────────────────────────────────── + + /** Generates one block or schematic ahead. */ + public void generate() { + if (g.state.waitForSchematicCompletion) return; + + Map chances = new HashMap<>(g.state.defaultChances); + if (g.state.schematicCooldown > 0 + || g.generatorOptions.contains(GeneratorOption.DISABLE_SCHEMATICS) + || g.profile.get("schematicDifficulty").asDouble() == 0.0 + || !g.state.schematicBlocks.isEmpty()) { + chances.remove(ParkourGenerator.BlockGenerationType.SCHEMATIC); + } + if (!g.profile.get("useSpecialBlocks").asBoolean()) { + chances.remove(ParkourGenerator.BlockGenerationType.SPECIAL); + } + if (chances.isEmpty()) chances.put(ParkourGenerator.BlockGenerationType.DEFAULT, 1.0); + + ParkourGenerator.BlockGenerationType jump = Probs.random(chances); + if (jump == ParkourGenerator.BlockGenerationType.SCHEMATIC && tryGenerateSchematic()) return; + + placeNormalBlock(jump); + } + + /** Generates {@code amount+1} blocks. */ + public void generate(int amount) { + for (int i = 0; i <= amount; i++) generate(); + } + + /** Seeds the history and generates the initial lead. */ + public void generateFirst(Location spawn, Location blockSpawn) { + g.state.playerSpawn = spawn; + g.state.lastStandingPlayerLocation = spawn; + g.state.blockSpawn = blockSpawn; + g.state.history.add(blockSpawn.getBlock()); + generate(g.profile.get("blockLead").asInt()); + } + + /** Clears schematic blocks and resets cooldown. */ + public void deleteSchematic() { + if (!g.state.deleteSchematic) return; + g.state.schematicBlocks.forEach(b -> b.setType(Material.AIR)); + g.state.schematicBlocks.clear(); + g.state.deleteSchematic = false; + g.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown"); + } + + // ── Block selection ──────────────────────────────────────────────────────── + + protected List selectBlocks() { + int height = Probs.random(g.state.heightChances); + int distance = Probs.random(g.state.distanceChances); + return List.of(selectNext(g.getLatest(), distance, height)); + } + + protected Block selectNext(Block current, int distance, int height) { + JumpDirector director = new JumpDirector( + BoundingBox.of(g.zone[0], g.zone[1]), g.getLatest().getLocation().toVector()); + + g.state.heading = director.getRecommendedHeading(g.state.heading); + height = director.getRecommendedHeight(height); + + // Restrict jumps after special blocks to prevent impossible jumps + Material lastType = g.getLatest().getType(); + switch (lastType) { + case SMOOTH_QUARTZ_SLAB -> { + height = Math.min(height, 0); // Can't jump up from slab + distance = Math.min(distance, 3); // Limit distance + } + case GLASS_PANE -> { + height = Math.min(height, 0); // Can't jump up from pane + distance = Math.min(distance, 3); // Limit distance + } + case OAK_FENCE, BIRCH_FENCE, SPRUCE_FENCE, DARK_OAK_FENCE, + JUNGLE_FENCE, ACACIA_FENCE, MANGROVE_FENCE, CHERRY_FENCE, + CRIMSON_FENCE, WARPED_FENCE, NETHER_BRICK_FENCE -> { + height = Math.min(height, 0); // Can't jump up from fence + distance = Math.min(distance, 3); // Limit distance + } + case PACKED_ICE -> { + distance = Math.min(distance, 3); // Ice is slippery, limit distance + } + case OAK_TRAPDOOR, BIRCH_TRAPDOOR, SPRUCE_TRAPDOOR, DARK_OAK_TRAPDOOR, + JUNGLE_TRAPDOOR, ACACIA_TRAPDOOR, MANGROVE_TRAPDOOR, CHERRY_TRAPDOOR, + CRIMSON_TRAPDOOR, WARPED_TRAPDOOR, IRON_TRAPDOOR -> { + height = Math.min(height, 0); // Can't jump up from trapdoor + distance = Math.min(distance, 2); // Very limited distance + } + case LADDER -> { + height = Math.min(height, 1); // Limited height from ladder + distance = Math.min(distance, 2); // Very limited distance + } + } + + // Clamp values to valid ranges for JumpOffsetGenerator + height = Math.max(-2, Math.min(1, height)); + distance = Math.max(1, Math.min(4, distance)); + + if (height > 0) distance = Math.max(distance - height, 1); + + double sd = g.generatorOptions.contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? 0.5 : 1; + int randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd); + + Vector offset = g.state.heading.clone().multiply(distance + 1).setY(height); + if (offset.getX() == 0) offset.setX(randomOffset); + else offset.setZ(randomOffset); + + offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection())); + + Block candidate = current.getLocation().add(offset).getBlock(); + + // Retry with JumpValidator if jump is impossible (up to 10 attempts) + JumpValidator validator = new JumpValidator(); + int attempts = 0; + while (!validator.canJump(current.getLocation(), candidate.getLocation()) && attempts < 10) { + // Reduce distance and height to make jump easier + distance = Math.max(1, distance - 1); + height = Math.max(-1, height - 1); + + randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd); + offset = g.state.heading.clone().multiply(distance + 1).setY(height); + if (offset.getX() == 0) offset.setX(randomOffset); + else offset.setZ(randomOffset); + offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection())); + + candidate = current.getLocation().add(offset).getBlock(); + attempts++; + } + + return candidate; + } + + protected BlockData selectBlockData() { + Style style = Registry.getStyle(g.profile.get("style").value()); + if (style == null) { + g.profile.set("style", Registry.getStyles().stream().findFirst().orElseThrow().getName()); + return selectBlockData(); + } + return style.getNext().createBlockData(); + } + + // ── Internal ─────────────────────────────────────────────────────────────── + + private void placeNormalBlock(ParkourGenerator.BlockGenerationType jump) { + List blocks = selectBlocks(); + if (blocks.isEmpty()) { + LoParkour.getPlugin().getLogger().severe("No blocks to generate!"); + return; + } + + List placed = new ArrayList<>(); + for (Block block : blocks) { + BlockData data; + + // Select JumpType based on chances (70% normal, 30% special types) + JumpType jumpType = selectJumpType(); + + if (jumpType != JumpType.NORMAL && !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) { + // Use JumpType material + data = jumpType.getRandomMaterial().createBlockData(); + } else if (jump == ParkourGenerator.BlockGenerationType.SPECIAL + && !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) { + data = Probs.random(g.state.specialChances); + } else { + data = selectBlockData(); + } + + if (data instanceof Fence) block = block.getLocation().subtract(0, 1, 0).getBlock(); + block.setBlockData(data, data instanceof Fence || data instanceof GlassPane); + placed.add(block); + } + + new ParkourBlockGenerateEvent(placed, g, g.player).call(); + g.effects.particles(placed); + g.effects.sound(placed); + g.state.history.addAll(placed); + g.state.schematicCooldown--; + } + + private JumpType selectJumpType() { + // Simple chances: 70% normal, 5% each for special types + Map chances = new HashMap<>(); + chances.put(JumpType.NORMAL, 70.0); + chances.put(JumpType.NEO_JUMP, 5.0); + chances.put(JumpType.HEAD_HITTER, 5.0); + chances.put(JumpType.FENCE_JUMP, 10.0); + chances.put(JumpType.TRAPDOOR_JUMP, 5.0); + chances.put(JumpType.LADDER_JUMP, 5.0); + + return Probs.random(chances); + } + + private boolean tryGenerateSchematic() { + var manager = LoParkour.getSchematicManager(); + if (manager == null) return false; + + var all = manager.getAllSchematics(); + if (all.isEmpty()) return false; + + double diff = g.profile.get("schematicDifficulty").asDouble(); + var candidates = all.values().stream() + .filter(s -> s.getMetadata() != null && Math.abs(s.getMetadata().getDifficulty() - diff) <= 0.26) + .toList(); + var pool = candidates.isEmpty() ? new ArrayList<>(all.values()) : candidates; + var schematic = pool.get(ThreadLocalRandom.current().nextInt(pool.size())); + + Location origin = g.getLatest().getLocation().add(g.state.heading.clone().multiply(2)); + List placed = rotatedPaste(schematic, origin); + if (placed.isEmpty()) return false; + + g.state.schematicBlocks.addAll(placed); + g.state.history.addAll(placed); + g.state.waitForSchematicCompletion = true; + g.state.deleteSchematic = false; + + new ParkourSchematicGenerateEvent(schematic, g, g.player).call(); + return true; + } + + private @NotNull List rotatedPaste( + dev.loki.loparkour.schematic.lpschem.LPSchematic schematic, Location location) { + int[] rotations = {0, 90, 180, 270}; + int rotation = rotations[ThreadLocalRandom.current().nextInt(rotations.length)]; + org.bukkit.World world = location.getWorld(); + if (world == null) return List.of(); + + if (rotation == 0) return schematic.paste(location, world); + try { + var t = new dev.loki.loparkour.schematic.lpschem.SchematicTransformer(schematic); + return t.rotate(rotation).paste(location, world); + } catch (Exception ex) { + LoParkour.getPlugin().getLogger().log(Level.WARNING, "Could not rotate schematic", ex); + return schematic.paste(location, world); + } + } + + private double angleInY(Vector a, Vector b) { + return Math.atan2(a.getX() * b.getZ() - a.getZ() * b.getX(), a.dot(b)); + } +} diff --git a/release/src/main/java/dev/loki/loparkour/generator/jump/JumpOffsetGenerator.java b/release/src/main/java/dev/loki/loparkour/generator/jump/JumpOffsetGenerator.java new file mode 100644 index 0000000..d95da1b --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/generator/jump/JumpOffsetGenerator.java @@ -0,0 +1,78 @@ +package dev.loki.loparkour.generator.jump; + +import dev.loki.loparkour.util.Probs; + +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +/** + * Generates a random offset (the sidestep) for a parkour block. + * Source: here. + */ +public class JumpOffsetGenerator { + + private final int maxOffset; + + /** + * Constructor. + * + * @param y The height difference. + */ + public JumpOffsetGenerator(int y, int distance) { + this.maxOffset = switch (y) { + case 1 -> switch (distance) { + case 1 -> 4; + case 2 -> 3; + default -> 2; + }; + case 0 -> switch (distance) { + case 1, 2 -> 4; + case 3 -> 3; + default -> 2; + }; + case -1 -> switch (distance) { + case 1, 2 -> 5; + case 3 -> 4; + default -> 3; + }; + case -2 -> switch (distance) { + case 1, 2 -> 5; + default -> 4; + }; + default -> { + // Fallback for unexpected values + dev.loki.loparkour.LoParkour.getPlugin().getLogger() + .warning("Unexpected jump height " + y + ", using default maxOffset=2"); + yield 2; + } + }; + } + + /** + * @param mean The mean (average) value for the offset. Usually 0 to avoid parkour going only left or right. + * @param standardDeviation The standard deviation. + * @param random The random instance. + * @return A random jump-able offset. + */ + public int getRandomOffset(double mean, double standardDeviation, Random random) { + Map distribution = java.util.stream.IntStream.range(-maxOffset, maxOffset + 1) + .boxed() + .collect(Collectors.toMap( + offset -> (double) offset, + offset -> Probs.normalpdf(mean, standardDeviation, (double) offset) + )); + + return (int) (double) Probs.random(distribution, random); + } + + /** + * @param mean The mean (average) value for the offset. Usually 0 to avoid parkour going only left or right. + * @param standardDeviation The standard deviation. + * @return A random jump-able offset. + */ + public int getRandomOffset(double mean, double standardDeviation) { + return getRandomOffset(mean, standardDeviation, ThreadLocalRandom.current()); + } +} diff --git a/release/src/main/java/dev/loki/loparkour/generator/lifecycle/GeneratorLifecycle.java b/release/src/main/java/dev/loki/loparkour/generator/lifecycle/GeneratorLifecycle.java new file mode 100644 index 0000000..4b6ec1d --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/generator/lifecycle/GeneratorLifecycle.java @@ -0,0 +1,265 @@ +package dev.loki.loparkour.generator.lifecycle; + +import dev.lolib.scheduler.Scheduler; +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.api.event.ParkourFallEvent; +import dev.loki.loparkour.api.event.ParkourScoreEvent; +import dev.loki.loparkour.config.Config; +import dev.loki.loparkour.config.Option; +import dev.loki.loparkour.generator.ParkourGenerator; +import dev.loki.loparkour.ghost.GhostData; +import dev.loki.loparkour.ghost.GhostManager; +import dev.loki.loparkour.ghost.GhostRecorder; +import dev.loki.loparkour.leaderboard.Leaderboard; +import dev.loki.loparkour.leaderboard.Score; +import dev.loki.loparkour.mode.Modes; +import dev.loki.loparkour.player.ParkourPlayer; +import dev.loki.loparkour.player.ParkourSpectator; +import dev.loki.loparkour.reward.Rewards; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.Iterator; + +/** + * Handles parkour lifecycle: tick, fall, reset, cleanup. + */ +public class GeneratorLifecycle { + + private final ParkourGenerator generator; + private dev.lolib.scheduler.ScheduledTask cleanupTask; + private GhostRecorder ghostRecorder; + private GhostManager ghostManager; + + public GeneratorLifecycle(ParkourGenerator generator) { + this.generator = generator; + + if (Config.CONFIG.getBoolean("ghost-mode.enabled")) { + this.ghostRecorder = new GhostRecorder(); + this.ghostManager = new GhostManager(); + this.ghostManager.loadGhosts(generator.getMode().getName()); + } + } + + public void startTick() { + generator.task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 0, 1); + cleanupTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::cleanupDistantBlocks, 0, Option.CLEANUP_INTERVAL); + + // Start ghost recording if enabled + if (ghostRecorder != null && ghostManager != null) { + ghostRecorder.startRecording(generator.player.getLocation()); + + // Spawn top ghosts + if (generator.state.playerSpawn != null) { + ghostManager.spawnGhosts( + generator.getMode().getName(), + generator.state.playerSpawn, + generator.player.player.getWorld() + ); + } + } + } + + public void tick() { + if (generator.state.stopped) { + generator.task.cancel(); + if (cleanupTask != null) cleanupTask.cancel(); + if (ghostManager != null) ghostManager.stopAllGhosts(); + return; + } + + generator.getPlayers().forEach(p -> { + updateVisualTime(p, p.selectedTime); + p.updateScoreboard(generator); + p.player.setSaturation(20); + + // Record ghost frame + if (ghostRecorder != null && ghostRecorder.isRecording()) { + ghostRecorder.recordFrame(p.getLocation()); + } + }); + generator.getSpectators().forEach(ParkourSpectator::update); + + if (generator.player.getLocation().getWorld() != generator.state.lastStandingPlayerLocation.getWorld()) return; + if (generator.player.getLocation().subtract(generator.state.lastStandingPlayerLocation).getY() < -10) { + fall(); + return; + } + + Block below = blockBelow(); + if (below == null) return; + + handleSchematicEndBlock(below); + + // Clean up schematic blocks that are behind the player + if (!generator.state.schematicBlocks.isEmpty()) { + int currentIdx = generator.state.history.indexOf(below); + if (currentIdx > 0) { + generator.state.schematicBlocks.removeIf(b -> { + int blockIdx = generator.state.history.indexOf(b); + if (blockIdx >= 0 && blockIdx < currentIdx - 5) { + b.setType(Material.AIR); + return true; + } + return false; + }); + } + } + + // Reset schematic wait if player is far from schematic blocks + if (generator.state.waitForSchematicCompletion && !generator.state.schematicBlocks.isEmpty()) { + boolean nearSchematic = generator.state.schematicBlocks.stream() + .anyMatch(b -> b.getLocation().distance(generator.player.getLocation()) < 15); + if (!nearSchematic) { + generator.state.waitForSchematicCompletion = false; + // Force delete schematic blocks + generator.state.schematicBlocks.forEach(b -> b.setType(Material.AIR)); + generator.state.schematicBlocks.clear(); + generator.state.deleteSchematic = false; + generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown"); + } + } + + if (!generator.state.history.contains(below)) return; + + int idx = generator.state.history.indexOf(below); + int delta = idx - generator.state.lastPositionIndexPlayer; + if (delta <= 0) return; + + generator.state.lastStandingPlayerLocation = generator.player.getLocation(); + + int lead = generator.profile.get("blockLead").asInt(); + if (generator.state.history.size() - idx <= lead) { + generator.generate(lead - (generator.state.history.size() - idx)); + } + generator.state.lastPositionIndexPlayer = idx; + + removeTrailBlocks(idx); + generator.placer.deleteSchematic(); + + int pts = Config.CONFIG.getBoolean("scoring.all-points") ? delta : 1; + for (int i = 0; i < pts; i++) score(); + + if (generator.state.start == null) generator.state.start = Instant.now(); + } + + private void handleSchematicEndBlock(Block below) { + if (generator.state.schematicBlocks.contains(below) + && below.getType() == Material.RED_WOOL + && !generator.state.deleteSchematic) { + + for (int i = 0; i < generator.profile.get("schematicDifficulty").asDouble() * 15; i++) { + score(); + } + generator.state.waitForSchematicCompletion = false; + generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown"); + generator.generate(generator.profile.get("blockLead").asInt()); + generator.state.deleteSchematic = true; + } + } + + private @Nullable Block blockBelow() { + Location loc = generator.player.getLocation().subtract(0, 1, 0); + Block b = loc.getBlock(); + if (b.getType() == Material.AIR) { + if (loc.subtract(0, 0.5, 0).getBlock().getType() == Material.AIR) return null; + b = loc.getBlock(); + } + return b; + } + + private void removeTrailBlocks(int idx) { + for (int i = idx - ParkourGenerator.BLOCK_TRAIL - 1; i >= idx - 4 * ParkourGenerator.BLOCK_TRAIL; i--) { + if (i > 0) generator.state.history.get(i).setType(Material.AIR); + } + } + + private void updateVisualTime(ParkourPlayer p, int selectedTime) { + int t = 18000 + selectedTime; + if (t >= 24000) t -= 24000; + p.player.setPlayerTime(t, false); + } + + public void score() { + generator.state.score++; + generator.state.totalScore++; + checkRewards(); + new ParkourScoreEvent(generator.player).call(); + } + + private void checkRewards() { + if (!Rewards.REWARDS_ENABLED || generator.state.score == 0) return; + + if (Rewards.SCORE_REWARDS.containsKey(generator.state.score)) { + Rewards.SCORE_REWARDS.get(generator.state.score).forEach(r -> r.execute(generator.player, generator.getMode())); + } + + int intervalScore = Config.CONFIG.getBoolean("scoring.rewards-use-total-score") + ? generator.state.totalScore + : generator.state.score; + + for (int interval : Rewards.INTERVAL_REWARDS.keySet()) { + if (intervalScore % interval == 0) { + Rewards.INTERVAL_REWARDS.get(interval).forEach(r -> r.execute(generator.player, generator.getMode())); + } + } + + String key = Integer.toString(generator.state.score); + if (Rewards.ONE_TIME_REWARDS.containsKey(generator.state.score) + && !generator.player.collectedRewards.contains(key)) { + Rewards.ONE_TIME_REWARDS.get(generator.state.score).forEach(r -> r.execute(generator.player, generator.getMode())); + generator.player.collectedRewards.add(key); + } + } + + public void fall() { + new ParkourFallEvent(generator.player).call(); + + // Save ghost if score is good enough + if (ghostRecorder != null && ghostManager != null && ghostRecorder.isRecording()) { + String modeName = generator.getMode().getName(); + int score = generator.state.score; + + if (ghostManager.shouldRecordGhost(modeName, score)) { + // Pass UUID as well as name — GhostManager names files by UUID + GhostData ghostData = ghostRecorder.stopRecording( + generator.player.getUUID(), + generator.player.getName(), + score + ); + ghostManager.saveGhost(modeName, ghostData); + generator.player.sendTranslated("ghost.saved", Integer.toString(score)); + } + } + + if (ghostManager != null) { + ghostManager.stopAllGhosts(); + } + + generator.reset(true); + } + + protected void cleanupDistantBlocks() { + if (generator.state.history.size() < Option.BLOCK_CLEANUP_DISTANCE * 2) return; + + Location loc = generator.player.getLocation(); + int removed = 0; + Iterator it = generator.state.history.iterator(); + + while (it.hasNext()) { + Block b = it.next(); + if (b.getLocation().distance(loc) > Option.BLOCK_CLEANUP_DISTANCE) { + b.setType(Material.AIR, false); + it.remove(); + removed++; + } else break; + } + + if (removed > 0) { + generator.state.lastPositionIndexPlayer = Math.max(0, generator.state.lastPositionIndexPlayer - removed); + } + } +} diff --git a/release/src/main/java/dev/loki/loparkour/ghost/GhostData.java b/release/src/main/java/dev/loki/loparkour/ghost/GhostData.java new file mode 100644 index 0000000..0786657 --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/ghost/GhostData.java @@ -0,0 +1,102 @@ +package dev.loki.loparkour.ghost; + +import org.jetbrains.annotations.NotNull; + +import java.io.*; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Immutable snapshot of a player's run: who ran it, their score, and every recorded frame. + * + *

Bugs fixed vs original: + *

    + *
  • Files were named {@code playerName.ghost} — two players with the same name + * overwrote each other, and a name like {@code ../../config} caused path traversal. + * UUID is now stored and used as the filename instead.
  • + *
  • File format is backwards-compatible: UUID is written first (new field), + * then the existing layout. Old files without a UUID are handled in + * {@link #loadFromFile} by catching the format mismatch and returning null.
  • + *
+ */ +public class GhostData { + + /** Binary format version — bump when changing the serialised layout. */ + private static final int FORMAT_VERSION = 2; + + private final UUID playerUuid; + private final String playerName; + private final int score; + private final List frames; + + public GhostData(@NotNull UUID playerUuid, @NotNull String playerName, + int score, @NotNull List frames) { + this.playerUuid = playerUuid; + this.playerName = playerName; + this.score = score; + this.frames = new ArrayList<>(frames); + } + + // ── Getters ─────────────────────────────────────────────────────────────── + + public UUID getPlayerUuid() { return playerUuid; } + public String getPlayerName() { return playerName; } + public int getScore() { return score; } + public List getFrames() { return frames; } + + // ── Serialisation ───────────────────────────────────────────────────────── + + public void saveToFile(@NotNull File file) throws IOException { + try (DataOutputStream out = new DataOutputStream( + new BufferedOutputStream(new FileOutputStream(file)))) { + out.writeInt(FORMAT_VERSION); // version header + out.writeUTF(playerUuid.toString()); // UUID (filename-safe, collision-free) + out.writeUTF(playerName); // display name (for the ghost label) + out.writeInt(score); + out.writeInt(frames.size()); + for (GhostFrame frame : frames) { + out.writeLong(frame.getTimestamp()); + out.writeDouble(frame.getX()); + out.writeDouble(frame.getY()); + out.writeDouble(frame.getZ()); + out.writeFloat(frame.getYaw()); + out.writeFloat(frame.getPitch()); + } + } + } + + /** + * Loads a ghost from file. + * + * @return The loaded data, or {@code null} if the file is an old v1 format + * (no version header / UUID) — caller should delete and ignore it. + */ + public static GhostData loadFromFile(@NotNull File file) throws IOException { + try (DataInputStream in = new DataInputStream( + new BufferedInputStream(new FileInputStream(file)))) { + + int version = in.readInt(); + if (version != FORMAT_VERSION) { + // Old format (v1) started with readUTF for playerName, not an int version. + // Those files are stale — return null so the manager can discard them. + return null; + } + + UUID uuid = UUID.fromString(in.readUTF()); + String name = in.readUTF(); + int score = in.readInt(); + int frameCount = in.readInt(); + + List frames = new ArrayList<>(frameCount); + for (int i = 0; i < frameCount; i++) { + frames.add(new GhostFrame( + in.readLong(), + in.readDouble(), in.readDouble(), in.readDouble(), + in.readFloat(), in.readFloat())); + } + + return new GhostData(uuid, name, score, frames); + } + } +} diff --git a/release/src/main/java/dev/loki/loparkour/ghost/GhostManager.java b/release/src/main/java/dev/loki/loparkour/ghost/GhostManager.java new file mode 100644 index 0000000..2c9cc1f --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/ghost/GhostManager.java @@ -0,0 +1,162 @@ +package dev.loki.loparkour.ghost; + +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.config.Config; +import org.bukkit.Location; +import org.bukkit.World; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.util.*; + +/** + * Loads, saves, and spawns ghost runs per mode. + * + *

Bugs fixed vs original: + *

    + *
  • Files were named {@code playerName.ghost} — collisions between players sharing + * a name, and path traversal via names like {@code ../../config}. + * Now named {@code uuid.ghost} — guaranteed unique and filesystem-safe.
  • + *
  • When evicting the lowest ghost, the old file was looked up by player name + * which failed if the name had changed since the run was saved.
  • + *
  • Old v1 files (no UUID) are detected via {@link GhostData#loadFromFile} + * returning null — they are deleted automatically on first load.
  • + *
+ */ +public class GhostManager { + + private static final int MAX_GHOSTS_PER_MODE = 3; + + private final File ghostFolder; + private final Map> ghostsByMode = new HashMap<>(); + private final List activeGhosts = new ArrayList<>(); + + public GhostManager() { + this.ghostFolder = LoParkour.getInFolder("ghosts"); + if (!ghostFolder.exists()) { + ghostFolder.mkdirs(); + } + } + + // ── Loading ─────────────────────────────────────────────────────────────── + + public void loadGhosts(@NotNull String mode) { + File modeFolder = new File(ghostFolder, sanitizeMode(mode)); + if (!modeFolder.exists()) return; + + File[] files = modeFolder.listFiles((dir, name) -> name.endsWith(".ghost")); + if (files == null) return; + + List ghosts = new ArrayList<>(); + for (File file : files) { + try { + GhostData data = GhostData.loadFromFile(file); + if (data == null) { + // Old v1 format — delete and skip + LoParkour.getPlugin().getLogger().info( + "Deleting outdated ghost file (old format): " + file.getName()); + file.delete(); + continue; + } + ghosts.add(data); + } catch (IOException e) { + LoParkour.getPlugin().getLogger().severe( + "Failed to load ghost file " + file.getName() + ": " + e.getMessage()); + } + } + + ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed()); + ghostsByMode.put(mode, ghosts); + } + + // ── Saving ──────────────────────────────────────────────────────────────── + + public void saveGhost(@NotNull String mode, @NotNull GhostData data) { + File modeFolder = new File(ghostFolder, sanitizeMode(mode)); + if (!modeFolder.exists()) modeFolder.mkdirs(); + + List ghosts = new ArrayList<>(ghostsByMode.getOrDefault(mode, new ArrayList<>())); + ghosts.add(data); + ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed()); + + // Evict lowest-score ghosts beyond the cap + while (ghosts.size() > MAX_GHOSTS_PER_MODE) { + GhostData evicted = ghosts.remove(ghosts.size() - 1); + // File is named by UUID — always finds the right file regardless of name changes + File evictedFile = ghostFile(modeFolder, evicted); + if (evictedFile.exists()) evictedFile.delete(); + } + + ghostsByMode.put(mode, ghosts); + + // Save new ghost — named by UUID, not by player name + File file = ghostFile(modeFolder, data); + try { + data.saveToFile(file); + } catch (IOException e) { + LoParkour.getPlugin().getLogger().severe( + "Failed to save ghost for " + data.getPlayerName() + ": " + e.getMessage()); + } + } + + // ── Spawning ────────────────────────────────────────────────────────────── + + public void spawnGhosts(@NotNull String mode, @NotNull Location startLocation, + @NotNull World world) { + if (!Config.CONFIG.getBoolean("ghost-mode.enabled")) return; + + stopAllGhosts(); + + List ghosts = ghostsByMode.get(mode); + if (ghosts == null || ghosts.isEmpty()) return; + + int showTop = Math.min(Config.CONFIG.getInt("ghost-mode.show-top"), ghosts.size()); + for (int i = 0; i < showTop; i++) { + GhostPlayer ghost = new GhostPlayer(ghosts.get(i), world); + ghost.spawn(startLocation); + activeGhosts.add(ghost); + } + } + + public void stopAllGhosts() { + activeGhosts.forEach(GhostPlayer::stop); + activeGhosts.clear(); + } + + // ── Queries ─────────────────────────────────────────────────────────────── + + @Nullable + public GhostData getTopGhost(@NotNull String mode) { + List ghosts = ghostsByMode.get(mode); + return (ghosts == null || ghosts.isEmpty()) ? null : ghosts.get(0); + } + + public List getTopGhosts(@NotNull String mode, int count) { + List ghosts = ghostsByMode.get(mode); + if (ghosts == null || ghosts.isEmpty()) return Collections.emptyList(); + return Collections.unmodifiableList(ghosts.subList(0, Math.min(count, ghosts.size()))); + } + + public boolean shouldRecordGhost(@NotNull String mode, int score) { + List ghosts = ghostsByMode.get(mode); + if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) return true; + return score > ghosts.get(ghosts.size() - 1).getScore(); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + /** Returns the ghost file for a run — always UUID-based, never name-based. */ + private File ghostFile(File modeFolder, GhostData data) { + return new File(modeFolder, data.getPlayerUuid().toString() + ".ghost"); + } + + /** + * Strips any path separators from mode names so they can't be used to + * escape the ghosts directory (e.g. mode name "../../etc"). + */ + private static String sanitizeMode(String mode) { + return mode.replaceAll("[/\\\\.]", "_"); + } +} diff --git a/release/src/main/java/dev/loki/loparkour/ghost/GhostPlayer.java b/release/src/main/java/dev/loki/loparkour/ghost/GhostPlayer.java new file mode 100644 index 0000000..ce98fff --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/ghost/GhostPlayer.java @@ -0,0 +1,78 @@ +package dev.loki.loparkour.ghost; + +import dev.lolib.scheduler.Scheduler; +import dev.lolib.scheduler.ScheduledTask; +import dev.loki.loparkour.LoParkour; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.EntityType; +import org.jetbrains.annotations.NotNull; + +/** + * Spawns and animates an ArmorStand that replays a {@link GhostData} run. + * + *

Bugs fixed vs original: + *

    + *
  • Duplicate import of {@code dev.lolib.scheduler.Scheduler} — removed.
  • + *
  • Duplicate field declaration {@code dev.lolib.scheduler.ScheduledTask task} + * shadowed the already-imported type — removed the fully-qualified duplicate.
  • + *
+ */ +public class GhostPlayer { + + private final GhostData data; + private final World world; + private ArmorStand entity; + private ScheduledTask task; + private int currentFrame = 0; + private long startTime; + + public GhostPlayer(@NotNull GhostData data, @NotNull World world) { + this.data = data; + this.world = world; + } + + public void spawn(@NotNull Location startLocation) { + if (data.getFrames().isEmpty()) return; + + entity = (ArmorStand) world.spawnEntity(startLocation, EntityType.ARMOR_STAND); + entity.setVisible(false); + entity.setGravity(false); + entity.setInvulnerable(true); + entity.setCustomName("§7" + data.getPlayerName() + " §8(Ghost)"); + entity.setCustomNameVisible(true); + + startTime = System.currentTimeMillis(); + currentFrame = 0; + + task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::update, 0, 1); + } + + private void update() { + if (entity == null || !entity.isValid()) { + stop(); + return; + } + + long elapsed = System.currentTimeMillis() - startTime; + + while (currentFrame < data.getFrames().size()) { + GhostFrame frame = data.getFrames().get(currentFrame); + if (frame.getTimestamp() > elapsed) break; + entity.teleport(frame.toLocation(world)); + currentFrame++; + } + + if (currentFrame >= data.getFrames().size()) stop(); + } + + public void stop() { + if (task != null) { task.cancel(); task = null; } + if (entity != null) { entity.remove(); entity = null; } + } + + public boolean isActive() { return entity != null && entity.isValid(); } + public String getPlayerName() { return data.getPlayerName(); } + public int getScore() { return data.getScore(); } +} diff --git a/release/src/main/java/dev/loki/loparkour/ghost/GhostRecorder.java b/release/src/main/java/dev/loki/loparkour/ghost/GhostRecorder.java new file mode 100644 index 0000000..dcc52b0 --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/ghost/GhostRecorder.java @@ -0,0 +1,61 @@ +package dev.loki.loparkour.ghost; + +import org.bukkit.Location; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Records a player's movement as a sequence of {@link GhostFrame}s. + * + *

Bugs fixed vs original: + *

    + *
  • {@code stopRecording} took only {@code playerName} — now also takes {@code playerUuid} + * so {@link GhostData} can store it and {@link GhostManager} can name files by UUID.
  • + *
+ */ +public class GhostRecorder { + + private static final double MIN_MOVEMENT = 0.1; + + private final List frames = new ArrayList<>(); + private Location lastLocation; + private long startTime; + private boolean recording = false; + + public void startRecording(@NotNull Location startLocation) { + frames.clear(); + lastLocation = startLocation.clone(); + startTime = System.currentTimeMillis(); + recording = true; + frames.add(new GhostFrame(0, startLocation.clone())); + } + + public void recordFrame(@NotNull Location currentLocation) { + if (!recording) return; + if (lastLocation.distance(currentLocation) < MIN_MOVEMENT) return; + + long timestamp = System.currentTimeMillis() - startTime; + frames.add(new GhostFrame(timestamp, currentLocation.clone())); + lastLocation = currentLocation.clone(); + } + + /** + * Stops recording and returns the completed ghost data. + * + * @param playerUuid UUID of the player — used as the filename in {@link GhostManager}. + * @param playerName Display name shown on the ghost label in-game. + * @param score The score achieved this run. + */ + public GhostData stopRecording(@NotNull UUID playerUuid, + @NotNull String playerName, + int score) { + recording = false; + return new GhostData(playerUuid, playerName, score, frames); + } + + public boolean isRecording() { return recording; } + public int getFrameCount() { return frames.size(); } +} diff --git a/release/src/main/java/dev/loki/loparkour/mode/CoopMode.java b/release/src/main/java/dev/loki/loparkour/mode/CoopMode.java new file mode 100644 index 0000000..66227d9 --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/mode/CoopMode.java @@ -0,0 +1,192 @@ +package dev.loki.loparkour.mode; + +import dev.loki.loparkour.config.Config; +import dev.loki.loparkour.config.Locales; +import dev.loki.loparkour.generator.ParkourGenerator; +import dev.loki.loparkour.leaderboard.Leaderboard; +import dev.loki.loparkour.menu.Menus; +import dev.loki.loparkour.player.ParkourPlayer; +import dev.loki.loparkour.player.ParkourUser; +import dev.loki.loparkour.session.Session; +import dev.loki.loparkour.world.Divider; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Co-op Mode — multiple players share one session and one score. + * + *

Implements {@link MultiMode} so it is excluded from the single-player + * mode selection menu in {@link dev.loki.loparkour.menu.play.SingleMenu}. + * Players join via /parkour coop or through a lobby invite flow.

+ * + *

Problems fixed vs previous version: + *

    + *
  • Did NOT implement MultiMode → showed up in single-player menu
  • + *
  • CoopGenerator was a private inner class — inaccessible from join/leave
  • + *
  • sharedScore was a separate field that duplicated state.score
  • + *
  • fall() called super.fall() THEN tried to clear state that was already reset
  • + *
  • playerContributions never cleared between falls correctly
  • + *
  • create(Player) silently created a solo session instead of rejecting
  • + *
+ */ +public class CoopMode implements MultiMode { + + private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE); + + // Active coop sessions — used so join() can add to an existing one + // Key = session, tracked via Divider automatically; this is just for lookup + // We look up by existing player's session instead. + + @Override + @NotNull + public String getName() { + return "coop"; + } + + /** + * Returns null so this mode never appears in any menu item list. + * Players join via lobby invite or command only. + */ + @Override + @Nullable + public dev.loki.loparkour.util.Item getItem(String locale) { + return null; + } + + @Override + @NotNull + public Leaderboard getLeaderboard() { + return leaderboard; + } + + /** + * Called when a player tries to start coop solo from a command/menu. + * Opens the lobby menu so they can invite others — does NOT create a session here. + * A session is created only once a second player joins via {@link #join}. + */ + @Override + public void create(Player player) { + if (!Config.CONFIG.getBoolean("joining")) { + player.sendMessage("§cJoining is currently disabled."); + return; + } + // Opening lobby menu lets player invite others — actual session created in join() + Menus.LOBBY.open(player); + } + + /** + * Adds a player to an existing coop session, or creates a new session if + * the target player is not yet in one. + */ + @Override + public void join(Player joiningPlayer, Session session) { + if (!Config.CONFIG.getBoolean("joining")) { + joiningPlayer.sendMessage("§cJoining is currently disabled."); + return; + } + + if (session.generator == null) return; + + // Register the joining player into the existing session + ParkourPlayer pp = ParkourUser.register(joiningPlayer, session); + session.addPlayers(pp); + pp.updateGeneratorSettings(session.generator); + + // Inform everyone + session.getPlayers().forEach(p -> + p.sendTranslated("modes.coop.player_joined", joiningPlayer.getName())); + } + + /** + * Removes a player from a coop session. + * If the last player leaves, the session ends normally. + */ + @Override + public void leave(Player player, Session session) { + ParkourPlayer pp = ParkourPlayer.getPlayer(player); + if (pp == null) return; + ParkourUser.leave(pp); + } + + @Override + public int getMaxPlayers() { + return Config.CONFIG.getInt("modes.coop.max-players"); + } + + // ── CoopGenerator ───────────────────────────────────────────────────────── + + /** + * Package-accessible so Session.create() lambda can reference it, + * and so join() can cast generator when needed. + */ + static class CoopGenerator extends ParkourGenerator { + + /** Individual block contributions per player UUID. */ + private final Map contributions = new HashMap<>(); + + public CoopGenerator(@NotNull Session session) { + super(session); + session.getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0)); + } + + @Override + protected void score() { + super.score(); // increments state.score and totalScore + + // Track which player was on the block (primary player for single, varies in multi) + contributions.merge(player.getUUID(), 1, Integer::sum); + + // Milestone every 50 points + if (state.score % 50 == 0) { + getPlayers().forEach(pp -> { + pp.player.sendTitle( + "§6Team: §e" + state.score, + "§aKeep it up!", + 5, 40, 10); + pp.player.playSound(pp.getLocation(), + Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.5f); + }); + } + } + + /** + * When a new player joins mid-game, add them to contributions map. + */ + public void onPlayerJoin(ParkourPlayer pp) { + contributions.putIfAbsent(pp.getUUID(), 0); + } + + @Override + protected void fall() { + // Show each player their individual contribution before reset + getPlayers().forEach(pp -> { + int contrib = contributions.getOrDefault(pp.getUUID(), 0); + int pct = state.score > 0 ? (contrib * 100 / state.score) : 0; + pp.sendTranslated("modes.coop.stats", + Integer.toString(state.score), + Integer.toString(contrib), + Integer.toString(pct)); + }); + + super.fall(); // handles reset, leaderboard, etc. + } + + @Override + public void reset(boolean regenerate) { + super.reset(regenerate); + contributions.clear(); + getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0)); + } + + @Override + public Mode getMode() { + return Modes.COOP; + } + } +} diff --git a/release/src/main/java/dev/loki/loparkour/mode/ElytraGenerator.java b/release/src/main/java/dev/loki/loparkour/mode/ElytraGenerator.java new file mode 100644 index 0000000..189df25 --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/mode/ElytraGenerator.java @@ -0,0 +1,750 @@ +package dev.loki.loparkour.mode; + +import dev.lolib.scheduler.Scheduler; +import dev.lolib.scheduler.ScheduledTask; +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.api.event.ParkourFallEvent; +import dev.loki.loparkour.api.event.ParkourScoreEvent; +import dev.loki.loparkour.config.Config; +import dev.loki.loparkour.generator.ParkourGenerator; +import dev.loki.loparkour.leaderboard.Leaderboard; +import dev.loki.loparkour.leaderboard.Score; +import dev.loki.loparkour.player.ParkourPlayer; +import dev.loki.loparkour.session.Session; +import dev.loki.loparkour.world.Divider; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.entity.Firework; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.FireworkMeta; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.NotNull; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Generator for Elytra mode. + * + *

Completely replaces block-based parkour with an aerial ring course: + *

    + *
  • Rings are placed ahead along a smooth curved path in the sky
  • + *
  • Each ring is vertical, perpendicular to the flight direction
  • + *
  • Player must fly through the center — detection uses plane-crossing math
  • + *
  • Firework usage is intercepted via event to enforce cooldown properly
  • + *
  • Fall = player touches ground / loses flight / flies too far from course
  • + *
+ */ +public class ElytraGenerator extends ParkourGenerator implements Listener { + + // ── Constants ───────────────────────────────────────────────────────────── + + /** How many rings behind the player to keep before deleting. */ + private static final int RING_TRAIL = 3; + + /** Ticks between particle ring renders (every 2 ticks = 10fps). */ + private static final int RENDER_INTERVAL = 2; + + /** Minimum Y height — if player drops below this they failed. */ + private static final double MIN_Y_OFFSET = -30.0; + + // Config-driven values cached at init + private int ringLead; + private double maxDeviation; + + /** Particle count per ring point. */ + private static final int PARTICLES_PER_POINT = 1; + + /** Number of particle points around each ring. */ + private static final int RING_POINTS = 36; + + // ── State ───────────────────────────────────────────────────────────────── + + private final List rings = new ArrayList<>(); + private int nextRingIndex = 0; // index of the next ring the player hasn't passed + private int tickCounter = 0; + private Instant startTime = null; + private int score = 0; + + private ScheduledTask tickTask; + private ScheduledTask renderTask; + + /** Firework cooldown per player UUID in ms. */ + private final Map fireworkCooldowns = new HashMap<>(); + + /** Starting Y for fall detection. */ + private double spawnY; + + // ── Constructor ─────────────────────────────────────────────────────────── + + public ElytraGenerator(@NotNull Session session) { + super(session); + this.ringLead = Config.CONFIG.getInt("modes.elytra.ring-lead"); + this.maxDeviation = Config.CONFIG.getDouble("modes.elytra.max-deviation"); + } + + // ── Mode identification ─────────────────────────────────────────────────── + + @Override + public Mode getMode() { + return Modes.ELYTRA; + } + + // ── Lifecycle: called by Session after island.build() ──────────────────── + + /** + * Called once when the generator is set up. + * We skip the block-based generateFirst and do our own aerial setup. + */ + @Override + public void generateFirst(Location spawn, Location blockSpawn) { + spawnY = spawn.getY(); + state.playerSpawn = spawn; + state.lastStandingPlayerLocation = spawn; + state.blockSpawn = blockSpawn; + + // Seed the ring path starting just in front of spawn + Vector initialHeading = state.heading.clone().normalize(); + Location ringOrigin = spawn.clone().add(initialHeading.clone().multiply(10)).add(0, 2, 0); + + generateRings(ringOrigin, initialHeading, ringLead); + + // Setup player + for (ParkourPlayer pp : getPlayers()) { + Player p = pp.player; + p.setGameMode(GameMode.ADVENTURE); + p.getInventory().clear(); + + // Elytra chestplate + p.getInventory().setChestplate(new ItemStack(Material.ELYTRA)); + + // Starting fireworks + int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks"); + p.getInventory().setItem(0, makeFirework(amount)); + p.getInventory().setHeldItemSlot(0); + + // Launch into glide + p.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4)); + p.setGliding(true); + + pp.sendTranslated("modes.elytra.start_hint"); + } + + // Register firework event listener + Bukkit.getPluginManager().registerEvents(this, LoParkour.getPlugin()); + + // Start ticks + tickTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 1, 1); + renderTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::renderRings, 0, RENDER_INTERVAL); + } + + // ── generateFirst is the entry — skip normal generate() completely ──────── + + @Override + public void generate() { /* not used in elytra mode */ } + + @Override + public void generate(int amount) { /* not used */ } + + @Override + public void startTick() { /* tick started in generateFirst */ } + + // ── Main tick ───────────────────────────────────────────────────────────── + + private void tick() { + if (state.stopped) { + stopTasks(); + return; + } + + tickCounter++; + + for (ParkourPlayer pp : getPlayers()) { + Player p = pp.player; + + // Ensure player stays gliding + if (!p.isGliding() && p.getGameMode() == GameMode.ADVENTURE) { + // Only force-glide if they're not on ground and have elytra + if (!p.isOnGround()) { + p.setGliding(true); + } + } + + // Update scoreboard + pp.updateScoreboard(this); + p.setSaturation(20); + + // Action bar: score + next ring distance + updateActionBar(pp); + } + + // Fall detection + checkFall(); + + // Ring crossing detection + checkRingCrossings(); + } + + // ── Ring generation ─────────────────────────────────────────────────────── + + /** + * Generates {@code count} new rings starting from {@code origin}, + * heading in {@code direction}, with smooth curve randomisation. + */ + private void generateRings(Location origin, Vector direction, int count) { + int distMin = Config.CONFIG.getInt("modes.elytra.ring-distance-min"); + int distMax = Config.CONFIG.getInt("modes.elytra.ring-distance-max"); + double ringSize = Config.CONFIG.getDouble("modes.elytra.ring-size"); + double maxYaw = Config.CONFIG.getDouble("modes.elytra.max-heading-change-horizontal"); + double maxPitch = Config.CONFIG.getDouble("modes.elytra.max-heading-change-vertical"); + double maxHeight = Config.CONFIG.getDouble("modes.elytra.max-height-above-spawn"); + + ThreadLocalRandom rng = ThreadLocalRandom.current(); + Location cursor = origin.clone(); + Vector heading = direction.clone().normalize(); + + // If we already have rings, continue from the last one + if (!rings.isEmpty()) { + ElytraRing last = rings.get(rings.size() - 1); + cursor = last.center.clone(); + heading = last.normal.clone(); + } + + for (int i = 0; i < count; i++) { + // Distance between rings + double dist = distMin + rng.nextDouble() * (distMax - distMin); + + // Smooth heading change using config-defined limits + double yawDelta = Math.toRadians(rng.nextDouble(-maxYaw, maxYaw)); + double pitchDelta = Math.toRadians(rng.nextDouble(-maxPitch, maxPitch)); + + heading = rotateVector(heading, yawDelta, pitchDelta); + heading.normalize(); + + // New ring center + Location center = cursor.clone().add(heading.clone().multiply(dist)); + + // Keep rings in a reasonable Y band + double targetY = Math.max(spawnY + 5, Math.min(spawnY + maxHeight, center.getY())); + center.setY(targetY); + + rings.add(new ElytraRing(rings.size(), center.clone(), heading.clone(), ringSize)); + cursor = center; + } + } + + // ── Ring crossing detection ─────────────────────────────────────────────── + + private void checkRingCrossings() { + if (rings.isEmpty() || nextRingIndex >= rings.size()) return; + + ParkourPlayer pp = player; // primary player + Location loc = pp.getLocation(); + + ElytraRing nextRing = rings.get(nextRingIndex); + + // Check if player crossed the ring's plane + if (nextRing.hasCrossed(loc, pp.player.getVelocity())) { + if (nextRing.isInside(loc)) { + // Perfect pass — score + onRingPassed(pp, nextRing, true); + } else { + // Crossed the plane but missed the ring hole + onRingMissed(pp, nextRing); + } + nextRingIndex++; + + // Generate more rings ahead + int ahead = rings.size() - nextRingIndex; + if (ahead < ringLead) { + generateRings(null, null, ringLead - ahead); + } + + // Clean up old rings + cleanOldRings(); + } + + // Check deviation — if player flew too far off course + double deviation = nextRing.center.distance(loc); + if (deviation > maxDeviation) { + triggerFall("flew off course"); + } + } + + private void onRingPassed(ParkourPlayer pp, ElytraRing ring, boolean centered) { + score++; + if (startTime == null) startTime = Instant.now(); + + new ParkourScoreEvent(pp).call(); + + // Visual + sound feedback + pp.player.spawnParticle(Particle.TOTEM, ring.center, 40, 0.3, 0.3, 0.3, 0.15); + pp.player.playSound(ring.center, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, centered ? 1.8f : 1.2f); + + // Update state score so scoreboard works + state.score = score; + state.totalScore++; + } + + private void onRingMissed(ParkourPlayer pp, ElytraRing ring) { + // Flash red to indicate miss — no score + pp.player.spawnParticle(Particle.REDSTONE, + ring.center, 20, 0.5, 0.5, 0.5, 0, + new Particle.DustOptions(Color.RED, 2.0f)); + pp.player.playSound(ring.center, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.5f); + } + + // ── Fall / Reset ────────────────────────────────────────────────────────── + + private void checkFall() { + for (ParkourPlayer pp : getPlayers()) { + Player p = pp.player; + double y = p.getLocation().getY(); + + // Touched ground or fell too far below spawn + if (p.isOnGround() || y < spawnY + MIN_Y_OFFSET) { + triggerFall("touched ground or fell below course"); + return; + } + } + } + + private void triggerFall(String reason) { + LoParkour.log("ElytraGenerator fall: " + reason); + new ParkourFallEvent(player).call(); + + // Save score to leaderboard if it's a new record + Leaderboard lb = getMode().getLeaderboard(); + if (lb != null) { + int record = lb.get(player.getUUID()).score(); + if (score > record) { + String time = getFormattedTime(); + getPlayers().forEach(p -> lb.put(p.getUUID(), + new Score(p.getName(), time, "0.0", score))); + } + } + + // Show fall message + sendFallMessage(); + + reset(true); + } + + private void sendFallMessage() { + Leaderboard lb = getMode().getLeaderboard(); + int record = lb != null ? lb.get(player.getUUID()).score() : 0; + + for (ParkourPlayer pp : getPlayers()) { + pp.sendTranslated("settings.parkour_settings.items.fall_message.divider"); + pp.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(score)); + pp.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime()); + pp.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record)); + if (score > record) { + pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.beat", + Integer.toString(score - record)); + } else if (score == record) { + pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.tied", "0"); + } else { + pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.miss", + Integer.toString(record - score)); + } + pp.sendTranslated("settings.parkour_settings.items.fall_message.divider"); + } + } + + @Override + public void reset(boolean regenerate) { + state.stopped = !regenerate; + + // Clear rings from world (remove barrier blocks) + rings.forEach(ElytraRing::remove); + rings.clear(); + nextRingIndex = 0; + score = 0; + state.score = 0; + startTime = null; + state.start = null; + tickCounter = 0; + fireworkCooldowns.clear(); + + if (!regenerate) { + stopTasks(); + unregisterListener(); + island.destroy(); + if (getPlayers().isEmpty()) { + getSpectators().forEach(s -> Modes.DEFAULT.create(s.player)); + } + return; + } + + // Teleport back to spawn and restart + for (ParkourPlayer pp : getPlayers()) { + pp.teleport(state.playerSpawn); + pp.player.setGliding(false); + pp.player.setVelocity(new Vector(0, 0, 0)); + + // Restore fireworks + int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks"); + pp.player.getInventory().clear(); + pp.player.getInventory().setChestplate(new ItemStack(Material.ELYTRA)); + pp.player.getInventory().setItem(0, makeFirework(amount)); + pp.player.setHeldItemSlot(0); + } + + // Re-generate rings + Vector initialHeading = state.heading.clone().normalize(); + Location ringOrigin = state.playerSpawn.clone() + .add(initialHeading.clone().multiply(10)).add(0, 2, 0); + generateRings(ringOrigin, initialHeading, ringLead); + + // Brief delay then launch + Scheduler.get(LoParkour.getPlugin()).runLater(() -> { + for (ParkourPlayer pp : getPlayers()) { + pp.player.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4)); + pp.player.setGliding(true); + } + }, 10L); + } + + // ── Ring rendering ──────────────────────────────────────────────────────── + + private void renderRings() { + if (state.stopped) return; + + Location playerLoc = player.getLocation(); + + for (int i = nextRingIndex; i < Math.min(nextRingIndex + ringLead, rings.size()); i++) { + ElytraRing ring = rings.get(i); + double dist = ring.center.distance(playerLoc); + if (dist > 80) continue; // Don't render very far rings + + // Colour gradient: next ring = gold, further = white, collected = skip + Color color = (i == nextRingIndex) ? Color.YELLOW + : (i == nextRingIndex + 1) ? Color.ORANGE + : Color.WHITE; + + renderRing(ring, color); + } + } + + private void renderRing(ElytraRing ring, Color color) { + World world = ring.center.getWorld(); + if (world == null) return; + + // Two orthogonal vectors in the ring's plane + Vector normal = ring.normal.clone().normalize(); + Vector up = findPerpendicular(normal); + Vector right = normal.clone().crossProduct(up).normalize(); + + double r = ring.radius; + Particle.DustOptions dust = new Particle.DustOptions(color, 1.5f); + + for (int i = 0; i < RING_POINTS; i++) { + double angle = 2 * Math.PI * i / RING_POINTS; + double x = r * Math.cos(angle); + double y = r * Math.sin(angle); + + Location p = ring.center.clone() + .add(right.clone().multiply(x)) + .add(up.clone().multiply(y)); + + world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, dust); + } + + // Inner ring (smaller, brighter) so it's visible + Particle.DustOptions innerDust = new Particle.DustOptions(Color.WHITE, 0.8f); + double innerR = r * 0.4; + for (int i = 0; i < RING_POINTS / 2; i++) { + double angle = 2 * Math.PI * i / (RING_POINTS / 2); + double x = innerR * Math.cos(angle); + double y = innerR * Math.sin(angle); + + Location p = ring.center.clone() + .add(right.clone().multiply(x)) + .add(up.clone().multiply(y)); + + world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, innerDust); + } + } + + // ── Firework event — cancel if on cooldown ──────────────────────────────── + + @EventHandler(priority = EventPriority.HIGH) + public void onFireworkUse(PlayerInteractEvent event) { + Player p = event.getPlayer(); + ParkourPlayer pp = ParkourPlayer.getPlayer(p); + if (pp == null || !(pp.session.generator instanceof ElytraGenerator gen)) return; + if (gen != this) return; + + // Only care about firework rockets + org.bukkit.inventory.ItemStack item = event.getItem(); + if (item == null || item.getType() != Material.FIREWORK_ROCKET) return; + + boolean isRightClick = event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR + || event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK; + if (!isRightClick) return; + if (!p.isGliding()) return; + + long now = System.currentTimeMillis(); + int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms"); + UUID uuid = p.getUniqueId(); + + Long lastUse = fireworkCooldowns.get(uuid); + if (lastUse != null && now - lastUse < cooldownMs) { + event.setCancelled(true); + long remaining = (cooldownMs - (now - lastUse) + 999) / 1000; + p.spigot().sendMessage( + net.md_5.bungee.api.ChatMessageType.ACTION_BAR, + net.md_5.bungee.api.chat.TextComponent.fromLegacyText( + "§c⏱ Firework cooldown: §f" + remaining + "s")); + return; + } + + fireworkCooldowns.put(uuid, now); + + // Spawn a boost firework at player location instead of vanilla behaviour + event.setCancelled(true); + spawnBoostFirework(p); + } + + /** + * Spawns a visual-only firework that gives the player a velocity boost. + * We do it manually so we control the power and remove the damage. + */ + private void spawnBoostFirework(Player p) { + double power = Config.CONFIG.getDouble("modes.elytra.boost-power"); + Vector dir = p.getLocation().getDirection().normalize(); + + // Remove one firework from inventory + ItemStack held = p.getInventory().getItemInMainHand(); + if (held.getType() == Material.FIREWORK_ROCKET) { + if (held.getAmount() > 1) { + held.setAmount(held.getAmount() - 1); + } else { + p.getInventory().setItemInMainHand(null); + } + } + + // Apply boost velocity + Vector boost = dir.multiply(power); + p.setVelocity(p.getVelocity().add(boost)); + p.setGliding(true); + + // Spawn visual firework that instantly explodes (no damage) + Firework fw = p.getWorld().spawn(p.getLocation(), Firework.class); + FireworkMeta meta = fw.getFireworkMeta(); + meta.addEffect(FireworkEffect.builder() + .with(FireworkEffect.Type.STAR) + .withColor(Color.AQUA, Color.WHITE) + .withFade(Color.YELLOW) + .withTrail() + .build()); + meta.setPower(0); + fw.setFireworkMeta(meta); + fw.setShotAtAngle(true); + + // Detonate immediately (no damage tick) + Scheduler.get(LoParkour.getPlugin()).runLater(() -> { + if (fw.isValid()) fw.detonate(); + }, 1L); + + p.playSound(p.getLocation(), Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 0.8f, 1.2f); + } + + // ── Action bar ──────────────────────────────────────────────────────────── + + private void updateActionBar(ParkourPlayer pp) { + if (rings.isEmpty() || nextRingIndex >= rings.size()) return; + + ElytraRing nextRing = rings.get(nextRingIndex); + double dist = Math.round(nextRing.center.distance(pp.getLocation()) * 10.0) / 10.0; + + // Firework cooldown indicator + long now = System.currentTimeMillis(); + int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms"); + Long lastUse = fireworkCooldowns.get(pp.getUUID()); + String cooldownStr; + if (lastUse != null && now - lastUse < cooldownMs) { + long remaining = (cooldownMs - (now - lastUse) + 999) / 1000; + cooldownStr = "§c⏱ " + remaining + "s"; + } else { + cooldownStr = "§a✔ Ready"; + } + + pp.player.spigot().sendMessage( + net.md_5.bungee.api.ChatMessageType.ACTION_BAR, + net.md_5.bungee.api.chat.TextComponent.fromLegacyText( + "§6⬤ §fRings: §e" + score + + " §7| §f➤ §b" + dist + "m" + + " §7| §f🚀 " + cooldownStr)); + } + + // ── Time helpers ────────────────────────────────────────────────────────── + + private String getFormattedTime() { + if (startTime == null) return "00:00:000"; + var ms = Instant.now().minusMillis(startTime.toEpochMilli()); + try { + return DateTimeFormatter.ofPattern("mm:ss:SSS").withZone(ZoneOffset.UTC).format(ms); + } catch (Exception e) { + return "00:00:000"; + } + } + + // ── Cleanup ─────────────────────────────────────────────────────────────── + + private void cleanOldRings() { + // Remove rings more than RING_TRAIL behind the player + int removeUntil = nextRingIndex - RING_TRAIL; + if (removeUntil <= 0) return; + + Iterator it = rings.iterator(); + int idx = 0; + while (it.hasNext()) { + ElytraRing ring = it.next(); + if (idx < removeUntil) { + ring.remove(); + it.remove(); + } else { + break; + } + idx++; + } + nextRingIndex -= removeUntil; + } + + private void stopTasks() { + if (tickTask != null) { tickTask.cancel(); tickTask = null; } + if (renderTask != null) { renderTask.cancel(); renderTask = null; } + } + + private void unregisterListener() { + HandlerList.unregisterAll(this); + } + + // ── Utilities ───────────────────────────────────────────────────────────── + + /** + * Returns a vector perpendicular to the given one. + * Used to form the ring's local coordinate axes. + */ + private static Vector findPerpendicular(Vector v) { + Vector perp = (Math.abs(v.getX()) < 0.9) + ? new Vector(1, 0, 0) + : new Vector(0, 1, 0); + return perp.subtract(v.clone().multiply(perp.dot(v))).normalize(); + } + + /** + * Rotates a vector by yaw (horizontal) and pitch (vertical) deltas. + */ + private static Vector rotateVector(Vector v, double yawRad, double pitchRad) { + // Rotate around Y axis (yaw) + double cos = Math.cos(yawRad); + double sin = Math.sin(yawRad); + double nx = v.getX() * cos - v.getZ() * sin; + double nz = v.getX() * sin + v.getZ() * cos; + v = new Vector(nx, v.getY(), nz); + + // Rotate around the local right axis (pitch) + Vector right = new Vector(v.getZ(), 0, -v.getX()).normalize(); + double c = Math.cos(pitchRad); + double s = Math.sin(pitchRad); + double vDotR = v.dot(right); + return new Vector( + v.getX() * c + (right.getY() * v.getZ() - right.getZ() * v.getY()) * s, + v.getY() * c + (right.getZ() * v.getX() - right.getX() * v.getZ()) * s, + v.getZ() * c + (right.getX() * v.getY() - right.getY() * v.getX()) * s + ); + } + + private static ItemStack makeFirework(int amount) { + ItemStack item = new ItemStack(Material.FIREWORK_ROCKET, amount); + FireworkMeta meta = (FireworkMeta) item.getItemMeta(); + if (meta != null) { + meta.addEffect(FireworkEffect.builder() + .with(FireworkEffect.Type.BALL) + .withColor(Color.AQUA) + .build()); + meta.setPower(1); + item.setItemMeta(meta); + } + return item; + } + + // ── Inner class: ElytraRing ─────────────────────────────────────────────── + + /** + * Represents one ring in the course. + * + *

A ring is defined by its center point and its normal vector (the direction + * of travel through it). The "inside" is a circle of {@code radius} around the center + * on the plane perpendicular to {@code normal}. + */ + public static class ElytraRing { + + public final int id; + public final Location center; + /** Direction of travel — the ring faces perpendicular to this. */ + public final Vector normal; + public final double radius; + + /** Signed distance of the player on the last tick (for plane crossing detection). */ + private double lastSignedDist = Double.NaN; + + /** Barrier blocks placed for this ring (optional, used if solid rings are enabled). */ + private final List barrierBlocks = new ArrayList<>(); + + public ElytraRing(int id, Location center, Vector normal, double radius) { + this.id = id; + this.center = center.clone(); + this.normal = normal.clone().normalize(); + this.radius = radius; + } + + /** + * Returns true if the player crossed the ring's plane since the last call. + * Uses the dot product of (playerPos - ringCenter) with the ring's normal. + * Sign change = crossing. + */ + public boolean hasCrossed(Location playerLoc, Vector velocity) { + Vector toPlayer = playerLoc.toVector().subtract(center.toVector()); + double signedDist = toPlayer.dot(normal); + + boolean crossed = false; + if (!Double.isNaN(lastSignedDist)) { + // Crossed if sign flipped (was positive, now negative or vice versa) + crossed = (lastSignedDist > 0) != (signedDist > 0); + } + lastSignedDist = signedDist; + return crossed; + } + + /** + * Returns true if the player's position (projected onto the ring's plane) + * is within the ring's radius — i.e. they flew through the hole. + */ + public boolean isInside(Location playerLoc) { + // Project player position onto ring's plane + Vector toPlayer = playerLoc.toVector().subtract(center.toVector()); + // Remove the component along the normal + Vector projected = toPlayer.clone().subtract(normal.clone().multiply(toPlayer.dot(normal))); + return projected.length() <= radius; + } + + /** Remove any barrier blocks this ring placed in the world. */ + public void remove() { + barrierBlocks.forEach(b -> b.setType(Material.AIR, false)); + barrierBlocks.clear(); + } + } +} diff --git a/release/src/main/java/dev/loki/loparkour/mode/ElytraMode.java b/release/src/main/java/dev/loki/loparkour/mode/ElytraMode.java new file mode 100644 index 0000000..597829a --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/mode/ElytraMode.java @@ -0,0 +1,55 @@ +package dev.loki.loparkour.mode; + +import dev.loki.loparkour.config.Config; +import dev.loki.loparkour.config.Locales; +import dev.loki.loparkour.leaderboard.Leaderboard; +import dev.loki.loparkour.player.ParkourPlayer; +import dev.loki.loparkour.session.Session; +import dev.loki.loparkour.util.Item; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Elytra Parkour Mode. + * Players fly through vertical rings generated ahead along a curved aerial path. + * No blocks on the ground — pure flight mechanics. + */ +public class ElytraMode implements Mode { + + private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE); + + @Override + @NotNull + public String getName() { + return "elytra"; + } + + @Override + @Nullable + public Item getItem(String locale) { + return Locales.getItem(locale, "modes.elytra"); + } + + @Override + @NotNull + public Leaderboard getLeaderboard() { + return leaderboard; + } + + @Override + public void create(Player player) { + if (!Config.CONFIG.getBoolean("joining")) { + player.sendMessage("§cJoining is currently disabled."); + return; + } + + ParkourPlayer pp = ParkourPlayer.getPlayer(player); + if (pp != null && pp.session.generator instanceof ElytraGenerator) { + return; + } + + player.closeInventory(); + Session.create(session -> new ElytraGenerator(session), null, null, player); + } +} diff --git a/release/src/main/java/dev/loki/loparkour/mode/RaceMode.java b/release/src/main/java/dev/loki/loparkour/mode/RaceMode.java new file mode 100644 index 0000000..d55f6de --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/mode/RaceMode.java @@ -0,0 +1,150 @@ +package dev.loki.loparkour.mode; + +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.config.Config; +import dev.loki.loparkour.config.Locales; +import dev.loki.loparkour.generator.ParkourGenerator; +import dev.loki.loparkour.leaderboard.Leaderboard; +import dev.loki.loparkour.player.ParkourPlayer; +import dev.loki.loparkour.session.Session; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Race Mode — reach the target score as fast as possible. + * Leaderboard sorts by time (fastest wins). + * + *

Bugs fixed vs previous version: + *

    + *
  • {@code tick()} called {@code super.tick()} AND checked score every tick — + * but super.tick() already calls {@code score()} internally, so the finish + * check ran one tick late causing an off-by-one where state.score could + * briefly exceed targetScore before finishing. Fixed by overriding + * {@link #score()} instead — finish check fires the exact moment target is hit.
  • + *
  • ActionBar was re-sent every tick inside tick() which conflicted with the + * parent's ActionBar updates. Moved to score() so it updates only on change.
  • + *
  • {@code reset(false)} was called via scheduler delay — if the player left the + * server in those 5 seconds it would NPE. Now guarded with null-check.
  • + *
  • {@code getFormattedTime()} is private in parent — accessed via + * {@code getDetailedTime()} which is protected. Fixed method call.
  • + *
+ */ +public class RaceMode implements Mode { + + private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.TIME); + + @Override + @NotNull + public String getName() { + return "race"; + } + + @Override + @Nullable + public dev.loki.loparkour.util.Item getItem(String locale) { + return Locales.getItem(locale, "modes.race"); + } + + @Override + @NotNull + public Leaderboard getLeaderboard() { + return leaderboard; + } + + @Override + public void create(Player player) { + if (!Config.CONFIG.getBoolean("joining")) { + player.sendMessage("§cJoining is currently disabled."); + return; + } + + ParkourPlayer pp = ParkourPlayer.getPlayer(player); + if (pp != null && pp.session.generator instanceof RaceGenerator) { + return; + } + + player.closeInventory(); + Session.create(session -> new RaceGenerator(session), null, null, player); + } + + // ── RaceGenerator ───────────────────────────────────────────────────────── + + private static class RaceGenerator extends ParkourGenerator { + + private final int targetScore; + private boolean raceFinished = false; + + public RaceGenerator(@NotNull Session session) { + super(session); + this.targetScore = Config.CONFIG.getInt("modes.race.target-score"); + } + + /** + * Hook into score() — the exact moment a new point is added. + * This ensures finish detection is frame-perfect with no off-by-one. + */ + @Override + protected void score() { + super.score(); + + // Update action bar on every new block + updateProgressBar(); + + if (!raceFinished && state.score >= targetScore) { + finishRace(); + } + } + + private void updateProgressBar() { + int remaining = Math.max(0, targetScore - state.score); + int filled = Math.min(20, state.score * 20 / targetScore); + String bar = "§a" + "█".repeat(filled) + "§7" + "█".repeat(20 - filled); + + getPlayers().forEach(pp -> + pp.player.spigot().sendMessage( + net.md_5.bungee.api.ChatMessageType.ACTION_BAR, + net.md_5.bungee.api.chat.TextComponent.fromLegacyText( + bar + " §e" + state.score + " §7/ §a" + targetScore + + (remaining > 0 ? " §7(§c" + remaining + " §7left)" : " §6§lDONE!") + ))); + } + + private void finishRace() { + raceFinished = true; + String time = getDetailedTime(); + + getPlayers().forEach(pp -> { + pp.player.sendTitle( + "§6§lRACE COMPLETE!", + "§aFinished in §f" + time, + 10, 80, 20); + pp.player.playSound(pp.getLocation(), + Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f); + pp.sendTranslated("modes.race.completed", + Integer.toString(targetScore), time); + }); + + // Save to leaderboard + registerScore(time, "1.0", targetScore); + + // Return to lobby after 5 seconds — guarded against null session + dev.lolib.scheduler.Scheduler.get(LoParkour.getPlugin()) + .runLater(() -> { + if (!state.stopped) reset(false); + }, 100L); + } + + @Override + public void reset(boolean regenerate) { + raceFinished = false; + super.reset(regenerate); + } + + @Override + public Mode getMode() { + return Modes.RACE; + } + } +} diff --git a/release/src/main/java/dev/loki/loparkour/mode/SpeedrunMode.java b/release/src/main/java/dev/loki/loparkour/mode/SpeedrunMode.java new file mode 100644 index 0000000..054e8af --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/mode/SpeedrunMode.java @@ -0,0 +1,154 @@ +package dev.loki.loparkour.mode; + +import dev.loki.loparkour.util.Item; +import dev.lolib.scheduler.Scheduler; +import dev.lolib.scheduler.ScheduledTask; +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.config.Config; +import dev.loki.loparkour.config.Locales; +import dev.loki.loparkour.generator.ParkourGenerator; +import dev.loki.loparkour.leaderboard.Leaderboard; +import dev.loki.loparkour.player.ParkourPlayer; +import dev.loki.loparkour.session.Session; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * Speedrun Mode — blocks disappear a short time after the player lands on them. + * + *

Bugs fixed vs previous version: + *

    + *
  • {@code onBlockTouch()} was never called from anywhere — blocks never disappeared. + * Fixed by hooking into {@link #score()} which fires exactly when the player + * steps onto a new block for the first time.
  • + *
  • Warning task reference was stored in a throwaway local, not in scheduledTasks, + * so it could never be cancelled on reset.
  • + *
  • {@code removalTime / 50} used integer division that truncated sub-second values + * to 0 ticks when blockLifetime < 0.05s. Now uses Math.max(1, ...).
  • + *
+ */ +public class SpeedrunMode implements Mode { + + private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE); + + @Override + @NotNull + public String getName() { + return "speedrun"; + } + + @Override + @Nullable + public Item getItem(String locale) { + return Locales.getItem(locale, "play.single.speedrun"); + } + + @Override + @NotNull + public Leaderboard getLeaderboard() { + return leaderboard; + } + + @Override + public void create(Player player) { + if (!Config.CONFIG.getBoolean("joining")) { + player.sendMessage("Joining is currently disabled."); + return; + } + + ParkourPlayer pp = ParkourPlayer.getPlayer(player); + if (pp != null && pp.session.generator instanceof SpeedrunGenerator) { + return; + } + + player.closeInventory(); + Session.create(session -> new SpeedrunGenerator(session), null, null, player); + } + + // ── SpeedrunGenerator ───────────────────────────────────────────────────── + + private static class SpeedrunGenerator extends ParkourGenerator { + + /** Tracks scheduled tasks per block. Key = block, value = [warningTask, removalTask]. */ + private final Map scheduledTasks = new HashMap<>(); + + public SpeedrunGenerator(@NotNull Session session) { + super(session); + } + + /** + * Called every time the player scores a point (= lands on a new block). + * We hook here instead of a separate onBlockTouch() because score() is the + * canonical place where "player stepped on block N" is detected. + */ + @Override + protected void score() { + super.score(); + + // The block the player just landed on is the previous-to-last in history + // (history.size()-1 is the leading block, size()-2 is where player stands) + int histSize = state.history.size(); + if (histSize < 2) return; + + Block stepped = state.history.get(histSize - 2); + if (stepped.getType() == Material.AIR) return; + if (scheduledTasks.containsKey(stepped)) return; // already scheduled + + scheduleBlockRemoval(stepped); + } + + private void scheduleBlockRemoval(@NotNull Block block) { + double blockLifetime = Config.CONFIG.getDouble("modes.speedrun.block-lifetime"); + double warningTime = Config.CONFIG.getDouble("modes.speedrun.warning-time"); + + // Convert seconds → ticks (20 ticks/s), minimum 1 tick + long removalTicks = Math.max(1, Math.round(blockLifetime * 20)); + long warningTicks = Math.max(1, Math.round(warningTime * 20)); + + ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> { + if (block.getType() != Material.AIR) { + player.player.playSound(block.getLocation(), + Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f); + player.player.spawnParticle(Particle.FLAME, + block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01); + } + }, warningTicks); + + ScheduledTask removalTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> { + if (block.getType() != Material.AIR) { + block.setType(Material.AIR, false); + } + scheduledTasks.remove(block); + }, removalTicks); + + // Store both so we can cancel them on reset + scheduledTasks.put(block, new ScheduledTask[]{warningTask, removalTask}); + } + + @Override + public void reset(boolean regenerate) { + // Cancel every pending warning and removal task + scheduledTasks.values().forEach(tasks -> { + for (ScheduledTask t : tasks) { + if (t != null) t.cancel(); + } + }); + scheduledTasks.clear(); + + super.reset(regenerate); + } + + @Override + public Mode getMode() { + return Modes.SPEEDRUN; + } + } +} diff --git a/release/src/main/java/dev/loki/loparkour/storage/SQLMigrationManager.java b/release/src/main/java/dev/loki/loparkour/storage/SQLMigrationManager.java new file mode 100644 index 0000000..06a17b8 --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/storage/SQLMigrationManager.java @@ -0,0 +1,50 @@ +package dev.loki.loparkour.storage; + +import dev.loki.loparkour.config.Option; + +/** + * Manages database schema migrations. + * + * @since 5.0.0 + */ +class SQLMigrationManager { + + private final SQLQueryExecutor queryExecutor; + + public SQLMigrationManager(SQLQueryExecutor queryExecutor) { + this.queryExecutor = queryExecutor; + } + + public void initializeDatabase() { + createDatabase(); + createOptionsTable(); + runMigrations(); + } + + private void createDatabase() { + queryExecutor.executeStaticUpdate("CREATE DATABASE IF NOT EXISTS `%s`;".formatted(Option.SQL_DB)); + queryExecutor.executeStaticUpdate("USE `%s`;".formatted(Option.SQL_DB)); + } + + private void createOptionsTable() { + queryExecutor.executeStaticUpdate( + ("CREATE TABLE IF NOT EXISTS `%soptions` " + + "(`uuid` CHAR(36) NOT NULL, `time` VARCHAR(8), `style` VARCHAR(32), `blockLead` INT, " + + "`useParticles` BOOLEAN, `useDifficulty` BOOLEAN, `useSpecial` BOOLEAN, " + + "`showFallMsg` BOOLEAN, `showScoreboard` BOOLEAN, PRIMARY KEY (`uuid`)) " + + "ENGINE = InnoDB CHARSET = utf8;").formatted(Option.SQL_PREFIX)); + } + + private void runMigrations() { + String prefix = Option.SQL_PREFIX; + + queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `time`;".formatted(prefix)); + queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `selectedTime` INT NOT NULL;".formatted(prefix)); + queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `collectedRewards` MEDIUMTEXT;".formatted(prefix)); + queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `locale` VARCHAR(8);".formatted(prefix)); + queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `schematicDifficulty` DOUBLE;".formatted(prefix)); + queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `sound` BOOLEAN;".formatted(prefix)); + queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useDifficulty`;".formatted(prefix)); + queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useStructure`;".formatted(prefix)); + } +} diff --git a/release/src/main/java/dev/loki/loparkour/storage/SQLQueryExecutor.java b/release/src/main/java/dev/loki/loparkour/storage/SQLQueryExecutor.java new file mode 100644 index 0000000..5ad52a8 --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/storage/SQLQueryExecutor.java @@ -0,0 +1,86 @@ +package dev.loki.loparkour.storage; + +import dev.loki.loparkour.LoParkour; + +import java.sql.PreparedStatement; +import java.sql.SQLException; + +/** + * Executes SQL queries with error handling. + * + *

All public methods accept parameters separately so callers never + * interpolate user data into SQL strings — preventing SQL injection.

+ * + * @since 5.0.0 + */ +class SQLQueryExecutor { + + private final SQLConnectionManager connectionManager; + + public SQLQueryExecutor(SQLConnectionManager connectionManager) { + this.connectionManager = connectionManager; + } + + /** + * Executes a DDL/static update with no user-supplied parameters. + * Only used for CREATE TABLE statements where all values are hardcoded. + */ + public void executeStaticUpdate(String sql) { + executeStaticUpdate(sql, false); + } + + /** + * Same as {@link #executeStaticUpdate(String)} but optionally suppresses errors. + * Used for ALTER TABLE migrations that may already have been applied. + */ + public void executeStaticUpdateSuppressed(String sql) { + executeStaticUpdate(sql, true); + } + + private void executeStaticUpdate(String sql, boolean suppressErrors) { + connectionManager.validateConnection(); + try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) { + if (stmt != null) stmt.executeUpdate(); + } catch (SQLException ex) { + if (!suppressErrors) { + LoParkour.getPlugin().getLogger().severe( + "Error while executing static update - " + ex.getMessage()); + } + } + } + + /** + * Executes a parameterised update. Values are bound via {@link PreparedStatement#setObject} + * — the JDBC driver handles escaping, no interpolation ever touches the SQL string. + * + * @param sql SQL with {@code ?} placeholders + * @param params Values to bind in order + */ + public void executeUpdate(String sql, Object... params) { + connectionManager.validateConnection(); + try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) { + if (stmt == null) return; + bindParams(stmt, params); + stmt.executeUpdate(); + } catch (SQLException ex) { + LoParkour.getPlugin().getLogger().severe( + "Error while executing update - " + ex.getMessage()); + } + } + + /** + * Prepares a statement for callers that need to read a {@link java.sql.ResultSet}. + * Parameters must be bound by the caller before executing. + */ + public PreparedStatement prepareStatement(String sql) { + return connectionManager.prepareStatement(sql); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static void bindParams(PreparedStatement stmt, Object[] params) throws SQLException { + for (int i = 0; i < params.length; i++) { + stmt.setObject(i + 1, params[i]); + } + } +} diff --git a/release/src/main/java/dev/loki/loparkour/storage/StorageSQL.java b/release/src/main/java/dev/loki/loparkour/storage/StorageSQL.java new file mode 100644 index 0000000..4f4dda3 --- /dev/null +++ b/release/src/main/java/dev/loki/loparkour/storage/StorageSQL.java @@ -0,0 +1,246 @@ +package dev.loki.loparkour.storage; + +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.config.Option; +import dev.loki.loparkour.leaderboard.Score; +import dev.loki.loparkour.player.ParkourPlayer; + +import org.bukkit.Bukkit; +import org.jetbrains.annotations.NotNull; + +import java.sql.*; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * MySQL storage manager. + * + *

Security: Every query that touches user-supplied data uses + * {@code ?} placeholders bound via {@link PreparedStatement#setObject}. + * No string interpolation of player names, UUIDs, scores, or any other + * external value ever reaches the SQL string itself.

+ * + * @since 5.0.0 + */ +class StorageSQL { + + private static final SQLConnectionManager connectionManager = new SQLConnectionManager(); + private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager); + private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor); + + private static boolean initialized = false; + private static final List pendingTableCreations = new CopyOnWriteArrayList<>(); + + // ── Connection lifecycle ────────────────────────────────────────────────── + + public static boolean isConnected() { + return connectionManager.isConnected(); + } + + public static void runWhenConnected(Runnable callback) { + connectionManager.runWhenConnected(callback); + } + + public static void close() { + connectionManager.close(); + } + + // ── Initialisation ──────────────────────────────────────────────────────── + + /** + * Queues creation of the leaderboard table for {@code mode}. + * Table name is constructed from a config-defined prefix (admin-controlled, + * not user input) so formatting here is safe. + */ + public static void init(String mode) { + // Table names are derived from admin config, not player input — safe to format. + String createTableSql = """ + CREATE TABLE IF NOT EXISTS `%s` + ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + name VARCHAR(16), + time VARCHAR(16), + difficulty VARCHAR(3), + score INT + ) CHARSET = utf8 ENGINE = InnoDB; + """.formatted(getTableName(mode)); + + if (!initialized) { + initialized = true; + pendingTableCreations.add(createTableSql); + Bukkit.getScheduler().runTaskAsynchronously(LoParkour.getPlugin(), StorageSQL::connect); + } else { + pendingTableCreations.add(createTableSql); + } + } + + // ── Leaderboard ─────────────────────────────────────────────────────────── + + public static @NotNull Map readScores(@NotNull String mode) { + // Table name from admin config — safe to format; no user data here. + String sql = "SELECT * FROM `%s`;".formatted(getTableName(mode)); + try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) { + if (stmt == null) return new HashMap<>(); + try (ResultSet rs = stmt.executeQuery()) { + Map scores = new HashMap<>(); + while (rs.next()) { + scores.put( + UUID.fromString(rs.getString("uuid")), + new Score( + rs.getString("name"), + rs.getString("time"), + rs.getString("difficulty"), + rs.getInt("score"))); + } + return scores; + } + } catch (SQLException ex) { + LoParkour.getPlugin().getLogger().severe( + "Error reading leaderboard for mode %s - %s".formatted(mode, ex.getMessage())); + return new HashMap<>(); + } + } + + /** + * Writes all scores for a mode. + * + *

Uses an INSERT … ON DUPLICATE KEY UPDATE with {@code ?} placeholders. + * Player name, time, difficulty come from the leaderboard record — all bound + * as parameters, never interpolated.

+ */ + public static void writeScores(@NotNull String mode, @NotNull Map scores) { + // Table name is from admin config — safe to format. + String sql = """ + INSERT INTO `%s` (uuid, name, time, difficulty, score) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + time = VALUES(time), + difficulty = VALUES(difficulty), + score = VALUES(score); + """.formatted(getTableName(mode)); + + new HashMap<>(scores).forEach((uuid, score) -> + queryExecutor.executeUpdate(sql, + uuid.toString(), + score.name(), + score.time(), + score.difficulty(), + score.score())); + } + + // ── Player settings ─────────────────────────────────────────────────────── + + /** + * Reads player settings from the options table. + * + *

UUID is bound as a parameter — never interpolated into the query string.

+ */ + public static void readPlayer(@NotNull ParkourPlayer player) { + // Table name from admin config — safe. UUID bound as parameter below. + String sql = "SELECT * FROM `%soptions` WHERE uuid = ?;".formatted(Option.SQL_PREFIX); + + try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) { + if (stmt == null) { + player.setSettings(new HashMap<>()); + return; + } + + // Bind UUID as parameter — no injection possible + stmt.setString(1, player.getUUID().toString()); + + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + player.setSettings(new HashMap<>()); + return; + } + + Map settings = new HashMap<>(); + for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) { + try { + settings.put(key, rs.getObject(key)); + } catch (SQLException ex) { + LoParkour.getPlugin().getLogger().severe( + "Error reading SQL column %s for %s - %s" + .formatted(key, player.getName(), ex.getMessage())); + } + } + player.setSettings(settings); + } + } catch (SQLException ex) { + LoParkour.getPlugin().getLogger().severe( + "Error reading player data for %s - %s".formatted(player.getName(), ex.getMessage())); + } + } + + /** + * Writes player settings to the options table. + * + *

All 12 player-controlled values (style, locale, rewards, etc.) are bound + * as {@code ?} parameters. None of them are interpolated into the SQL string.

+ */ + public static void writePlayer(@NotNull ParkourPlayer player) { + DecimalFormat df = new DecimalFormat("#.######", + DecimalFormatSymbols.getInstance(Locale.ENGLISH)); + double schemDiff = df.format(player.schematicDifficulty) != null + ? player.schematicDifficulty : 0.0; + + // Table name from admin config — safe to format. + String sql = """ + INSERT INTO `%soptions` + (uuid, style, blockLead, useParticles, useSpecial, showFallMsg, + showScoreboard, selectedTime, collectedRewards, locale, + schematicDifficulty, sound) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + style = VALUES(style), + blockLead = VALUES(blockLead), + useParticles = VALUES(useParticles), + useSpecial = VALUES(useSpecial), + showFallMsg = VALUES(showFallMsg), + showScoreboard = VALUES(showScoreboard), + selectedTime = VALUES(selectedTime), + collectedRewards = VALUES(collectedRewards), + locale = VALUES(locale), + schematicDifficulty = VALUES(schematicDifficulty), + sound = VALUES(sound); + """.formatted(Option.SQL_PREFIX); + + queryExecutor.executeUpdate(sql, + player.getUUID().toString(), // 1 uuid + player.style, // 2 style + player.blockLead, // 3 blockLead + player.particles, // 4 useParticles + player.useSpecialBlocks, // 5 useSpecial + player.showFallMessage, // 6 showFallMsg + player.showScoreboard, // 7 showScoreboard + player.selectedTime, // 8 selectedTime + String.join(",", player.collectedRewards), // 9 collectedRewards + player.locale, // 10 locale + schemDiff, // 11 schematicDifficulty + player.sound // 12 sound + ); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + /** + * Table name is built from the admin-configured SQL prefix and the internal + * mode name (e.g. "default", "speedrun"). Neither is user-supplied. + */ + private static String getTableName(String mode) { + return "%sleaderboard-%s".formatted(Option.SQL_PREFIX, mode); + } + + private static void connect() { + connectionManager.connect(); + migrationManager.initializeDatabase(); + + for (String sql : pendingTableCreations) { + queryExecutor.executeStaticUpdate(sql); + } + pendingTableCreations.clear(); + } +} diff --git a/release/src/main/resources/config.yml b/release/src/main/resources/config.yml new file mode 100644 index 0000000..b16f55d --- /dev/null +++ b/release/src/main/resources/config.yml @@ -0,0 +1,524 @@ +#===============================================================================# +# # +# 88 88888888ba # +# 88 88 "8b # +# 88 88 ,8P # +# 88 88aaaaaa8P' # +# 88 88""""""' # +# 88 88 # +# 88 88 # +# 88 88 # +# # +#===============================================================================# + +#===============================================================================# +# # +# (c) loki 2026 # +# # +#===============================================================================# + +# -= Debugging =- +debug: false + +# -= Enable joining =- +# Whether players can join the parkour. +# When joining is disabled, the IP instance on this server will only read data from leaderboard databases. +# Set joining to false if you want to create a cross-server leaderboard on this server. +# true = players can join, false = players can't join. +joining: true + +# -= Proxy options =- +# Change the proxy settings. Applies to Velocity as well. +# This also applies to servers who only want to run this plugin. +bungeecord: + + # -= Proxy enabled =- + # Enabling this will make players who join the server automatically join the parkour. + # You can enable this even without BungeeCord to make this a parkour-only server. + # True = players will automatically join the parkour on join, false = players have to use the menu/parkour command. + enabled: false + + # -= Proxy return server =- + # The server the players will be returned to after they leave. + return_server: 'Lobby' + + # -= Go back enabled =- + # Whether players should be teleported to a specific location after leaving. + # True = players get teleported to a specific location when they leave, false = they go to their location when they joined. + go-back-enabled: false + + # -= Go back location =- + # The place players will go back to when they leave + # Only applies if proxy mode is disabled and the go-back-enabled option is true. + # Syntax: '(x,y,z,worldName)' + go-back: '(0,100,0,world)' + + # -= Go back axes =- + # Sets the pitch and yaw of the go-back location. + # The first argument is the pitch, second yaw. + go-back-axes: '0,0' + +# -= Storage update interval =- +# The amount of seconds between every disk/database storage update. +# In this update, the in-memory scores will be written to storage. +# A lower number might reduce the chance of losing data on server crashes, +# but will increase the amount of requests to the disk/database. +# (requires a restart, default = 300) +storage-update-interval: 300 + +# -= MySQL =- +# The options for using MySQL as storage. +sql: + + # -= MySQL enabled =- + # Using databases makes you able to sync player data across multiple servers. + # True = data will be stored in a database, false = data will be stored locally. + enabled: false + + # -= MySQL settings =- + url: 'localhost' + port: 3306 + username: 'efnilite' + password: 'super secret password' + database: 'ip' + + # -= MySQL prefix =- + # The prefix given to any table names + # The default is ''. Example: 'ip-' -> this would make the table 'ip-players' + prefix: '' + +# -= World options =- +world: + + # -= World name =- + # The world in which parkour will take place + name: 'loparkour' + + # -= Delete world on reload =- + # Deletes the world on reload/restart of the server. + # This makes sure there are no ghost blocks left when a server crash occurs. + # Disable this if you want to have a custom map. + delete-on-reload: true + + # -= World fall back =- + # The world players will be teleported to if they accidentally join the Parkour world without joining parkour + fall-back: 'world' + +# -= General options =- +options: + + # -= Leads options =- + leads: + + # -= Leads amounts =- + # The lead amounts a player can select in their options menu. + # Values also need to be above 1 and below 64. + amount: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + + # -= Time options =- + time: + + # -= Time format =- + # Choose between: + # 24 - 24 hour clock. (00:00, 12:00, 23:59) + # 12 - 12 hour clock. (12:00 AM, 12:00 PM, 11:59 PM) + format: 24 + + # -= Score time format =- + # H = hours, m = minutes, s = seconds, S = milliseconds + score-format: "mm:ss:SSS" + + # -= Inventory handling =- + # Toggles the inventory handling system. + # True = players' inventories get saved and cleared when they join the parkour and when they leave they get their items back + # False = players keep their inventories when they join the parkour + inventory-handling: true + + # -= Inventory saving =- + # Logs inventories of the players that join to the inventories/ folder. + # It is highly recommended to keep this enabled. Server crashes or errors while leaving might cause + # inventories of players to be lost if this is set to false! + # Use /ip recoverinventory to recover inventories of players who joined the parkour and who lost their inventories in a crash, etc. + inventory-saving: true + + # -= Alternate inventory saving command =- + # If you don't trust this plugin to save inventories, you can select another plugin to execute a command + # to save players' inventories. + # Example: /inventory save %player% + # This command will be executed by console. + alt-inventory-saving-command: '' + + # -= Disables inventory blocks =- + # Disables the ability for a player to open blocks with an inventory. + disable-inventory-blocks: false + + # -= Hotbar slots =- + # Configure which hotbar slots (0-8) to use for parkour items + # Items will be placed in these slots in order: play, community, settings, lobby, quit + hotbar-slots: + play: 1 + community: 3 + settings: 4 + lobby: 5 + quit: 7 + +# -= Permissions options =- +# These permissions are used to determine which things the player can change. +# Find them at https://efnilite.dev/projects/ip/wiki +permissions: + + # -= Permissions enabled =- + # If permission handling is enabled. + # True = players need a permission to join, view the leaderboard, etc., false = players can do and view anything + # from this plugin besides admin commands. + enabled: false + + # -= Per style permissions =- + # Gives every style a permission. The permission is the name of the style. + # Syntax: ip.settings.styles.