From 7dd26408f9e112feacb5de66034ac9888a6b372d Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 20 Apr 2026 18:22:40 +0200 Subject: [PATCH] clear --- ...2026-04-09-gravity-shift-hardcore-modes.md | 441 ---------- 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 ------- 20 files changed, 4469 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-09-gravity-shift-hardcore-modes.md delete mode 100644 release/GUIDE.md delete mode 100644 release/src/main/java/dev/loki/loparkour/LoParkour.java delete mode 100644 release/src/main/java/dev/loki/loparkour/generator/jump/BlockPlacer.java delete mode 100644 release/src/main/java/dev/loki/loparkour/generator/jump/JumpOffsetGenerator.java delete mode 100644 release/src/main/java/dev/loki/loparkour/generator/lifecycle/GeneratorLifecycle.java delete mode 100644 release/src/main/java/dev/loki/loparkour/ghost/GhostData.java delete mode 100644 release/src/main/java/dev/loki/loparkour/ghost/GhostManager.java delete mode 100644 release/src/main/java/dev/loki/loparkour/ghost/GhostPlayer.java delete mode 100644 release/src/main/java/dev/loki/loparkour/ghost/GhostRecorder.java delete mode 100644 release/src/main/java/dev/loki/loparkour/mode/CoopMode.java delete mode 100644 release/src/main/java/dev/loki/loparkour/mode/ElytraGenerator.java delete mode 100644 release/src/main/java/dev/loki/loparkour/mode/ElytraMode.java delete mode 100644 release/src/main/java/dev/loki/loparkour/mode/RaceMode.java delete mode 100644 release/src/main/java/dev/loki/loparkour/mode/SpeedrunMode.java delete mode 100644 release/src/main/java/dev/loki/loparkour/storage/SQLMigrationManager.java delete mode 100644 release/src/main/java/dev/loki/loparkour/storage/SQLQueryExecutor.java delete mode 100644 release/src/main/java/dev/loki/loparkour/storage/StorageSQL.java delete mode 100644 release/src/main/resources/config.yml delete mode 100644 release/src/main/resources/locales/en.yml diff --git a/docs/superpowers/plans/2026-04-09-gravity-shift-hardcore-modes.md b/docs/superpowers/plans/2026-04-09-gravity-shift-hardcore-modes.md deleted file mode 100644 index c45b0df..0000000 --- a/docs/superpowers/plans/2026-04-09-gravity-shift-hardcore-modes.md +++ /dev/null @@ -1,441 +0,0 @@ -# GravityShift и Hardcore Modes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Реализовать GravityShiftMode (случайные эффекты зелий) и HardcoreMode (сброс наград при падении) - -**Architecture:** Оба режима следуют паттерну Mode + внутренний Generator класс. GravityShiftGenerator переопределяет score() для применения эффектов, HardcoreGenerator переопределяет fall() для сброса наград. - -**Tech Stack:** Bukkit/Spigot API, Java 21, существующая архитектура Mode/ParkourGenerator - ---- - -## Task 1: Implement GravityShiftMode - -**Files:** -- Create: `src/main/java/dev/loki/loparkour/mode/GravityShiftMode.java` - -- [ ] **Step 1: Create GravityShiftMode with inner Generator** - -```java -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.player.ParkourPlayer; -import dev.loki.loparkour.session.Session; -import dev.loki.loparkour.util.Item; -import org.bukkit.entity.Player; -import org.bukkit.potion.PotionEffect; -import org.bukkit.potion.PotionEffectType; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; - -/** - * Gravity Shift Mode — random potion effects applied every N jumps. - */ -public class GravityShiftMode implements Mode { - - private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE); - - @Override - @NotNull - public String getName() { - return "gravity-shift"; - } - - @Override - @Nullable - public Item getItem(String locale) { - return Locales.getItem(locale, "modes.gravity-shift"); - } - - @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 GravityShiftGenerator) { - return; - } - - player.closeInventory(); - Session.create(session -> new GravityShiftGenerator(session), null, null, player); - } - - // ── GravityShiftGenerator ───────────────────────────────────────────────── - - private static class GravityShiftGenerator extends ParkourGenerator { - - private final int interval; - private final Map effects; - private final Random random; - private final Map jumpCounts; - - public GravityShiftGenerator(@NotNull Session session) { - super(session); - this.random = new Random(); - this.jumpCounts = new HashMap<>(); - - // Load config - this.interval = Config.CONFIG.isPath("modes.gravity-shift.interval") - ? Config.CONFIG.getInt("modes.gravity-shift.interval") : 10; - - this.effects = new HashMap<>(); - loadEffect("jump-boost", PotionEffectType.JUMP); - loadEffect("speed", PotionEffectType.SPEED); - loadEffect("slowness", PotionEffectType.SLOW); - loadEffect("levitation", PotionEffectType.LEVITATION); - - // Initialize jump counts for all players - for (ParkourPlayer pp : getPlayers()) { - jumpCounts.put(pp.getUUID(), 0); - } - } - - private void loadEffect(String key, PotionEffectType type) { - String basePath = "modes.gravity-shift.effects." + key; - boolean enabled = Config.CONFIG.isPath(basePath + ".enabled") - ? Config.CONFIG.getBoolean(basePath + ".enabled") : false; - - if (!enabled) { - return; - } - - int amplifier = Config.CONFIG.isPath(basePath + ".amplifier") - ? Config.CONFIG.getInt(basePath + ".amplifier") : 1; - int duration = Config.CONFIG.isPath(basePath + ".duration") - ? Config.CONFIG.getInt(basePath + ".duration") : 30; - - effects.put(type, new EffectConfig(amplifier, duration * 20)); - } - - @Override - protected void score() { - super.score(); - - // Apply effects to all players - for (ParkourPlayer pp : getPlayers()) { - UUID uuid = pp.getUUID(); - int count = jumpCounts.getOrDefault(uuid, 0) + 1; - jumpCounts.put(uuid, count); - - if (count % interval == 0 && !effects.isEmpty()) { - applyRandomEffect(pp.getPlayer()); - } - } - } - - private void applyRandomEffect(Player player) { - if (player == null || effects.isEmpty()) { - return; - } - - List types = new ArrayList<>(effects.keySet()); - PotionEffectType selected = types.get(random.nextInt(types.size())); - EffectConfig config = effects.get(selected); - - player.removePotionEffect(selected); - player.addPotionEffect(new PotionEffect( - selected, - config.durationTicks, - config.amplifier, - false, - true, - true - )); - } - - @Override - public void reset(boolean regenerate) { - // Clear effects and reset counts - for (ParkourPlayer pp : getPlayers()) { - Player player = pp.getPlayer(); - if (player != null) { - for (PotionEffectType type : effects.keySet()) { - player.removePotionEffect(type); - } - } - jumpCounts.put(pp.getUUID(), 0); - } - - super.reset(regenerate); - } - - @Override - public Mode getMode() { - return Modes.GRAVITY_SHIFT; - } - - private record EffectConfig(int amplifier, int durationTicks) {} - } -} -``` - -- [ ] **Step 2: Commit GravityShiftMode** - -```bash -git add src/main/java/dev/loki/loparkour/mode/GravityShiftMode.java -git commit -m "feat: implement GravityShiftMode with random potion effects" -``` - ---- - -## Task 2: Implement HardcoreMode - -**Files:** -- Create: `src/main/java/dev/loki/loparkour/mode/HardcoreMode.java` - -- [ ] **Step 1: Create HardcoreMode with inner Generator** - -```java -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.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; - -/** - * Hardcore Mode — lose all collected rewards on fall. - */ -public class HardcoreMode implements Mode { - - private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE); - - @Override - @NotNull - public String getName() { - return "hardcore"; - } - - @Override - @Nullable - public Item getItem(String locale) { - return Locales.getItem(locale, "modes.hardcore"); - } - - @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 HardcoreGenerator) { - return; - } - - player.closeInventory(); - Session.create(session -> new HardcoreGenerator(session), null, null, player); - } - - // ── HardcoreGenerator ───────────────────────────────────────────────────── - - private static class HardcoreGenerator extends ParkourGenerator { - - private final boolean resetRewards; - - public HardcoreGenerator(@NotNull Session session) { - super(session); - this.resetRewards = Config.CONFIG.isPath("modes.hardcore.reset-rewards") - ? Config.CONFIG.getBoolean("modes.hardcore.reset-rewards") : true; - } - - @Override - public void fall() { - if (resetRewards) { - for (ParkourPlayer pp : getPlayers()) { - pp.collectedRewards.clear(); - } - } - - super.fall(); - } - - @Override - public Mode getMode() { - return Modes.HARDCORE; - } - } -} -``` - -- [ ] **Step 2: Commit HardcoreMode** - -```bash -git add src/main/java/dev/loki/loparkour/mode/HardcoreMode.java -git commit -m "feat: implement HardcoreMode with reward reset on fall" -``` - ---- - -## Task 3: Register modes in Modes.java - -**Files:** -- Modify: `src/main/java/dev/loki/loparkour/mode/Modes.java` - -- [ ] **Step 1: Read Modes.java to find registration pattern** - -```bash -cat src/main/java/dev/loki/loparkour/mode/Modes.java -``` - -- [ ] **Step 2: Add static fields for new modes** - -Add after existing mode declarations (e.g., after `SPEEDRUN`, `ELYTRA`): - -```java -public static GravityShiftMode GRAVITY_SHIFT; -public static HardcoreMode HARDCORE; -``` - -- [ ] **Step 3: Register modes in init() method** - -Add in `init()` method after existing registrations: - -```java -GRAVITY_SHIFT = register(new GravityShiftMode()); -HARDCORE = register(new HardcoreMode()); -``` - -- [ ] **Step 4: Commit registration** - -```bash -git add src/main/java/dev/loki/loparkour/mode/Modes.java -git commit -m "feat: register GravityShift and Hardcore modes" -``` - ---- - -## Task 4: Add locale entries - -**Files:** -- Modify: `src/main/resources/lang/en.yml` -- Modify: `src/main/resources/lang/ru.yml` - -- [ ] **Step 1: Add English locale entries** - -Add to `en.yml` under `modes:` section: - -```yaml - gravity-shift: - item: POTION - name: 'Gravity Shift' - description: 'Random potion effects every few jumps' - hardcore: - item: SKELETON_SKULL - name: 'Hardcore' - description: 'Lose all rewards on fall' -``` - -- [ ] **Step 2: Add Russian locale entries** - -Add to `ru.yml` under `modes:` section: - -```yaml - gravity-shift: - item: POTION - name: 'Гравитационный сдвиг' - description: 'Случайные эффекты зелий каждые несколько прыжков' - hardcore: - item: SKELETON_SKULL - name: 'Хардкор' - description: 'Потеря всех наград при падении' -``` - -- [ ] **Step 3: Commit locale entries** - -```bash -git add src/main/resources/lang/en.yml src/main/resources/lang/ru.yml -git commit -m "feat: add locale entries for GravityShift and Hardcore modes" -``` - ---- - -## Task 5: Test both modes - -**Files:** -- None (manual testing) - -- [ ] **Step 1: Build plugin** - -```bash -./gradlew clean build -``` - -Expected: BUILD SUCCESSFUL - -- [ ] **Step 2: Copy to server** - -```bash -cp build/libs/LoParkour-*.jar /path/to/server/plugins/ -``` - -- [ ] **Step 3: Start server and test GravityShift** - -1. Start server -2. Join parkour with `/lp` -3. Switch to gravity-shift mode -4. Make 10+ jumps -5. Verify random potion effects appear - -Expected: Effects like Jump Boost, Speed, Slowness, Levitation apply randomly every 10 jumps - -- [ ] **Step 4: Test Hardcore mode** - -1. Switch to hardcore mode -2. Collect some rewards (if available) -3. Fall off parkour -4. Check if rewards were cleared - -Expected: `player.collectedRewards` should be empty after fall - -- [ ] **Step 5: Verify config loading** - -Check server logs for any config errors related to: -- `modes.gravity-shift.interval` -- `modes.gravity-shift.effects.*` -- `modes.hardcore.reset-rewards` - -Expected: No errors, default values used if config missing - ---- - -## Execution Handoff - -Plan complete and saved to `docs/superpowers/plans/2026-04-09-gravity-shift-hardcore-modes.md`. - -**Two execution options:** - -**1. Subagent-Driven (recommended)** - Fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints - -**Which approach?** diff --git a/release/GUIDE.md b/release/GUIDE.md deleted file mode 100644 index 94e59c6..0000000 --- a/release/GUIDE.md +++ /dev/null @@ -1,230 +0,0 @@ -# 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 deleted file mode 100644 index 27a000d..0000000 --- a/release/src/main/java/dev/loki/loparkour/LoParkour.java +++ /dev/null @@ -1,269 +0,0 @@ -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 deleted file mode 100644 index 8227ebf..0000000 --- a/release/src/main/java/dev/loki/loparkour/generator/jump/BlockPlacer.java +++ /dev/null @@ -1,281 +0,0 @@ -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 deleted file mode 100644 index d95da1b..0000000 --- a/release/src/main/java/dev/loki/loparkour/generator/jump/JumpOffsetGenerator.java +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 4b6ec1d..0000000 --- a/release/src/main/java/dev/loki/loparkour/generator/lifecycle/GeneratorLifecycle.java +++ /dev/null @@ -1,265 +0,0 @@ -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 deleted file mode 100644 index 0786657..0000000 --- a/release/src/main/java/dev/loki/loparkour/ghost/GhostData.java +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 2c9cc1f..0000000 --- a/release/src/main/java/dev/loki/loparkour/ghost/GhostManager.java +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index ce98fff..0000000 --- a/release/src/main/java/dev/loki/loparkour/ghost/GhostPlayer.java +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index dcc52b0..0000000 --- a/release/src/main/java/dev/loki/loparkour/ghost/GhostRecorder.java +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 66227d9..0000000 --- a/release/src/main/java/dev/loki/loparkour/mode/CoopMode.java +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index 189df25..0000000 --- a/release/src/main/java/dev/loki/loparkour/mode/ElytraGenerator.java +++ /dev/null @@ -1,750 +0,0 @@ -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 deleted file mode 100644 index 597829a..0000000 --- a/release/src/main/java/dev/loki/loparkour/mode/ElytraMode.java +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index d55f6de..0000000 --- a/release/src/main/java/dev/loki/loparkour/mode/RaceMode.java +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 054e8af..0000000 --- a/release/src/main/java/dev/loki/loparkour/mode/SpeedrunMode.java +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index 06a17b8..0000000 --- a/release/src/main/java/dev/loki/loparkour/storage/SQLMigrationManager.java +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 5ad52a8..0000000 --- a/release/src/main/java/dev/loki/loparkour/storage/SQLQueryExecutor.java +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 4f4dda3..0000000 --- a/release/src/main/java/dev/loki/loparkour/storage/StorageSQL.java +++ /dev/null @@ -1,246 +0,0 @@ -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 deleted file mode 100644 index b16f55d..0000000 --- a/release/src/main/resources/config.yml +++ /dev/null @@ -1,524 +0,0 @@ -#===============================================================================# -# # -# 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.