From 3100c532299750bdfbf5186297f98d86a0bab2e0 Mon Sep 17 00:00:00 2001 From: loki Date: Wed, 1 Apr 2026 16:21:09 +0200 Subject: [PATCH] Update dependencies and improve configuration management - Updated LoLib dependency to version 3.0.0 in build.gradle.kts - Added HikariCP and MySQL connector dependencies for improved database handling - Enhanced jar minimization process to exclude JDBC pool and driver - Refactored configuration management by introducing ConfigAccessor and ConfigLoader for better file handling and validation - Removed deprecated GenerationOptions class and integrated its functionality into the new structure - Improved command error handling and added cooldown hints for admin commands --- TODO.md | 209 ++-- build.gradle.kts | 13 +- .../java/dev/loki/loparkour/LoParkour.java | 27 +- .../dev/loki/loparkour/LoParkourCommand.java | 35 +- .../dev/loki/loparkour/api/ParkourAPI.java | 1 - .../java/dev/loki/loparkour/api/Registry.java | 1 - .../event/ParkourSchematicGenerateEvent.java | 4 +- .../command/AdminCommandHandler.java | 21 +- .../command/PlayerCommandHandler.java | 22 +- .../dev/loki/loparkour/config/Config.java | 239 +---- .../loki/loparkour/config/ConfigAccessor.java | 141 +++ .../loki/loparkour/config/ConfigLoader.java | 87 ++ .../loki/loparkour/config/ConfigUpdater.java | 368 +++---- .../loparkour/config/GenerationOptions.java | 117 --- .../dev/loki/loparkour/config/Locales.java | 365 +++---- .../dev/loki/loparkour/config/Option.java | 263 +++-- .../loparkour/config/ParticleOptions.java | 44 - .../dev/loki/loparkour/config/SqlOptions.java | 27 - .../generator/GeneratorProfileManager.java | 143 +++ .../loparkour/generator/GeneratorState.java | 5 +- .../generator/GeneratorStatistics.java | 97 ++ .../dev/loki/loparkour/generator/Island.java | 112 ++- .../loparkour/generator/ParkourGenerator.java | 266 ++---- .../generator/effect/EffectManager.java | 1 - .../loparkour/generator/jump/BlockPlacer.java | 436 +++------ .../generator/jump/BlockSelector.java | 141 +++ .../generator/jump/JumpCalculator.java | 165 ++++ .../generator/lifecycle/GeneratorCleanup.java | 120 +++ .../lifecycle/GeneratorLifecycle.java | 296 +----- .../lifecycle/LifecycleEventManager.java | 100 ++ .../lifecycle/LifecycleTickManager.java | 78 ++ .../lifecycle/LifecycleVisualUpdater.java | 50 + .../lifecycle/PlayerInteractionHandler.java | 103 ++ .../dev/loki/loparkour/hook/VaultHook.java | 2 - .../loparkour/leaderboard/Leaderboard.java | 59 +- .../listener/ParkourRestrictionListener.java | 18 +- .../dev/loki/loparkour/menu/DynamicMenu.java | 4 +- .../java/dev/loki/loparkour/menu/LPMenu.java | 3 +- .../dev/loki/loparkour/menu/MainMenu.java | 1 - .../dev/loki/loparkour/menu/MenuStub.java | 32 +- .../menu/community/LeaderboardsMenu.java | 39 +- .../menu/community/SingleLeaderboardMenu.java | 39 +- .../loki/loparkour/menu/play/PlayMenu.java | 1 - .../loki/loparkour/menu/play/SingleMenu.java | 44 +- .../loparkour/menu/play/SpectatorMenu.java | 39 +- .../loparkour/menu/settings/LangMenu.java | 2 +- .../menu/settings/ParkourSettingsMenu.java | 1 - .../dev/loki/loparkour/mode/CoopMode.java | 8 +- .../dev/loki/loparkour/mode/DefaultMode.java | 2 - .../loki/loparkour/mode/ElytraGenerator.java | 898 ++++-------------- .../dev/loki/loparkour/mode/RaceMode.java | 1 + .../loki/loparkour/mode/SpectatorMode.java | 2 - .../dev/loki/loparkour/mode/SpeedrunMode.java | 10 +- .../loparkour/mode/elytra/ElytraConfig.java | 48 + .../loparkour/mode/elytra/ElytraPhysics.java | 140 +++ .../loparkour/mode/elytra/ElytraRenderer.java | 101 ++ .../loparkour/mode/elytra/ElytraRing.java | 80 ++ .../mode/elytra/ElytraRingGenerator.java | 128 +++ .../loki/loparkour/player/ParkourPlayer.java | 33 +- .../loparkour/player/ParkourSpectator.java | 10 +- .../loki/loparkour/player/ParkourUser.java | 1 + .../player/PlayerSettingsManager.java | 53 +- .../loparkour/player/ScoreboardManager.java | 1 + .../loparkour/player/data/InventoryData.java | 57 +- .../dev/loki/loparkour/reward/Rewards.java | 9 + .../loki/loparkour/schematic/Schematics.java | 17 +- .../schematic/lpschem/LPSchematic.java | 2 - .../dev/loki/loparkour/session/Session.java | 286 ++---- .../session/SessionPlayerManager.java | 147 +++ .../session/SessionStateManager.java | 105 ++ .../storage/SQLConnectionManager.java | 86 +- .../loki/loparkour/storage/SQLDataMapper.java | 120 +++ .../loparkour/storage/SQLQueryBuilder.java | 104 ++ .../loki/loparkour/storage/StorageSQL.java | 370 +++----- .../dev/loki/loparkour/style/RandomStyle.java | 7 +- .../dev/loki/loparkour/util/Materials.java | 111 +++ .../java/dev/loki/loparkour/util/Menu.java | 5 +- .../dev/loki/loparkour/util/PagedMenu.java | 9 +- .../dev/loki/loparkour/util/SliderItem.java | 2 - .../dev/loki/loparkour/world/Divider.java | 2 - .../java/dev/loki/loparkour/world/World.java | 62 +- src/main/resources/config.yml | 50 +- src/main/resources/generation.yml | 62 +- .../generator/jump/JumpValidatorTest.java | 7 +- .../loki/loparkour/style/RandomStyleTest.java | 25 + .../world/WorldNameValidationTest.java | 30 + 86 files changed, 4324 insertions(+), 3248 deletions(-) create mode 100644 src/main/java/dev/loki/loparkour/config/ConfigAccessor.java create mode 100644 src/main/java/dev/loki/loparkour/config/ConfigLoader.java delete mode 100644 src/main/java/dev/loki/loparkour/config/GenerationOptions.java delete mode 100644 src/main/java/dev/loki/loparkour/config/ParticleOptions.java delete mode 100644 src/main/java/dev/loki/loparkour/config/SqlOptions.java create mode 100644 src/main/java/dev/loki/loparkour/generator/GeneratorProfileManager.java create mode 100644 src/main/java/dev/loki/loparkour/generator/GeneratorStatistics.java create mode 100644 src/main/java/dev/loki/loparkour/generator/jump/BlockSelector.java create mode 100644 src/main/java/dev/loki/loparkour/generator/jump/JumpCalculator.java create mode 100644 src/main/java/dev/loki/loparkour/generator/lifecycle/GeneratorCleanup.java create mode 100644 src/main/java/dev/loki/loparkour/generator/lifecycle/LifecycleEventManager.java create mode 100644 src/main/java/dev/loki/loparkour/generator/lifecycle/LifecycleTickManager.java create mode 100644 src/main/java/dev/loki/loparkour/generator/lifecycle/LifecycleVisualUpdater.java create mode 100644 src/main/java/dev/loki/loparkour/generator/lifecycle/PlayerInteractionHandler.java create mode 100644 src/main/java/dev/loki/loparkour/mode/elytra/ElytraConfig.java create mode 100644 src/main/java/dev/loki/loparkour/mode/elytra/ElytraPhysics.java create mode 100644 src/main/java/dev/loki/loparkour/mode/elytra/ElytraRenderer.java create mode 100644 src/main/java/dev/loki/loparkour/mode/elytra/ElytraRing.java create mode 100644 src/main/java/dev/loki/loparkour/mode/elytra/ElytraRingGenerator.java create mode 100644 src/main/java/dev/loki/loparkour/session/SessionPlayerManager.java create mode 100644 src/main/java/dev/loki/loparkour/session/SessionStateManager.java create mode 100644 src/main/java/dev/loki/loparkour/storage/SQLDataMapper.java create mode 100644 src/main/java/dev/loki/loparkour/storage/SQLQueryBuilder.java create mode 100644 src/main/java/dev/loki/loparkour/util/Materials.java create mode 100644 src/test/java/dev/loki/loparkour/style/RandomStyleTest.java create mode 100644 src/test/java/dev/loki/loparkour/world/WorldNameValidationTest.java diff --git a/TODO.md b/TODO.md index c45c0c2..934dde9 100644 --- a/TODO.md +++ b/TODO.md @@ -1,177 +1,82 @@ # LoParkour — TODO -> Обновлено: 2026-03-07 +> Обновлено: 2026-04-02. Длинный хвост старых аудитов и дублей: **[ARCHIVE_TODO.md](ARCHIVE_TODO.md)**. ## Легенда -- 🔴 Критично — баг/краш/не компилируется -- 🟡 Важно — фича заявлена но не работает -- 🟢 Планово — улучшение + +- 🔴 Критично — баг/краш/безопасность +- 🟡 Важно — заявлено, но не работает / серьёзные проблемы +- 🟢 Планово — улучшение / техдолг - ✅ Готово --- -## 🔴 Критично — из code review +## Главный план (стабилизация) -| # | Баг | Файл | Что сделать | -|---|-----|------|-------------| -| 1 | ~~Ghost файлы по имени игрока~~ | `GhostManager.java`, `GhostData.java` | ✅ Используется UUID вместо имени | -| 2 | ~~`GhostRecorder.stopRecording()` не передаёт UUID~~ | `GhostRecorder.java`, `GeneratorLifecycle.java` | ✅ Добавлен параметр UUID | -| 3 | ~~`new Random()` в горячих методах~~ | `BlockPlacer.java` | ✅ Заменено на `ThreadLocalRandom.current()` | -| 4 | ~~`ConcurrentModificationException` в `reset()`~~ | `ParkourGenerator.java` | ✅ Синхронизация через копирование списка | -| 5 | ~~Сохраняется только DEFAULT лидерборд~~ | `LoParkour.java` disable() | ✅ Сохраняются все режимы (Speedrun, Race, Coop, Elytra) | -| 6 | ~~`cleanupDistantBlocks()` вызывается дважды~~ | `GeneratorLifecycle.java` | ✅ Убран дублирующий вызов | -| 7 | ~~`System.err.println` в JumpOffsetGenerator~~ | `JumpOffsetGenerator.java` | ✅ Используется Logger | -| 8 | ~~`disable()` глотает все исключения~~ | `LoParkour.java` | ✅ Логируются ошибки | -| 9 | ~~Первый блок может быть специальным~~ | `BlockPlacer.java` | ✅ Всегда полный блок из конфига | +Консолидировано после обхода: меню/util, `LoParkour`, `LoParkourCommand`, join/teleport, `SessionStateManager`, `Island.build()`, Locales, LoLib 3.x. + +### P0 — блокер загрузки или геймплей + +| # | Задача | Статус | +|---|--------|--------| +| 1 | LoLib 3.x в classpath | ✅ `build.gradle.kts` → `libs/lolib-3.0.0.jar` | +| 2 | `GeneratorCleanup` — хвост только **сзади** по индексу в `history` | ✅ | +| 3 | Потокобезопасность `history` | ✅ `synchronizedList`, снимок при полном `reset()` | + +### P1 — важно + +| # | Задача | Статус | +|---|--------|--------| +| 4 | Хотбар: клик по предмету | ✅ `isSimilar` в restriction listener | +| 5 | SQL `readPlayer` на корректном потоке | ✅ | + +### P2 — проверить после P0–P1 + +| # | Задача | Статус | +|---|--------|--------| +| 6 | `LifecycleTickManager` / cleanup / time UI | ✅ | +| 7 | Scoring в воздухе | ✅ документировано (стояние на блоке) | + +### Контекст (не в «критичном» списке) + +- Порядок `generateFirst` / телепорт из `Island.build()` — ок при неизменённом порядке вызовов. +- `history.contains(Block)` — Bukkit сравнивает по миру и координатам. +- `InventoryType.CHEST` в GUI — при странных кейсах проверить тип инвентаря. + +### Порядок работ (регрессия) + +1. `./gradlew build`, старт без `LinkageError`. +2. Ручной прогон паркура: счёт, меню, scoreboard, режимы. --- -## 🟡 Важно — улучшения безопасности +## Follow-up (сделано в коде) -| # | Проблема | Файл | Что сделать | -|---|----------|------|-------------| -| 9 | ~~Отсутствует rate limiting~~ | `PlayerCommandHandler.java` | ✅ Cooldown уже реализован (2.5 сек на join/leave) | -| 10 | ~~Нет валидации конфигурации~~ | `Config.java` | ✅ Добавлена валидация критичных значений | -| 11 | ~~`loadIslandSchematic()` возвращает null~~ | `ParkourGenerator.java` | ✅ Fallback на дефолтный схематик "island" | -| 12 | ~~Hardcoded строки вместо локализации~~ | `MainMenu.java`, `CoopMode.java`, `RaceMode.java`, `ElytraMode.java` | ✅ Используется `Locales.getString()` | +- **Leaderboard**: sync-таймер; итерация/снимки без гонок с `synchronizedMap`. +- **AdminCommandHandler**: cooldown 2.5 с на `forcejoin`, `forceleave`, `reset`, `recoverinventory`; при блокировке — сообщение. +- **SQLConnectionManager**: HikariCP + `mysql-connector-j` в shadow. +- **SQL password**: если задана непустая **`LOPARKOUR_SQL_PASSWORD`**, она перекрывает `config.yml` → `sql.password`. +- **World**: валидация имени; папка через `getWorldContainer()`; безопасное удаление; кик только если мир не null. +- **LoParkourCommand**: `MultiMode` для join к другу; tab-complete; leaderboard без хака; «too many args». +- **PlayerCommandHandler**: `/parkour leaderboard` без второго аргумента открывает меню лидербордов. +- **RandomStyle** / **ElytraGenerator**: защита от пустых списков. +- **InventoryData**: новые сохранения в **YAML**; legacy binary читается один раз при миграции. --- -## 🟢 Планово — оптимизация +## DuelMode -| # | Улучшение | Файл | Что сделать | -|---|-----------|------|-------------| -| 13 | ~~`getPlayers()` создаёт новый список каждый раз~~ | `ParkourPlayer.java` | ✅ Оптимизирован `getPlayer()` без создания списка | -| 14 | ~~Магические числа~~ | `BlockPlacer.java`, `GeneratorLifecycle.java` | ✅ Вынесены в константы (MAX_RETRIES, FALL_DISTANCE, etc) | -| 15 | ~~`selectJumpType()` хардкод шансов~~ | `BlockPlacer.java` | ✅ Вынесено в generation.yml | -| 16 | ~~SQL Injection в writeScores/writePlayer~~ | `StorageSQL.java` | ✅ Переписано на PreparedStatement с параметрами | +**Отдельная крупная фича** (не багфикс): PvP/дуэльный режим из старых заметок — проектировать и оценивать отдельно (сессии, арена, матчмейкинг, награды). Детали и старые идеи см. в **[ARCHIVE_TODO.md](ARCHIVE_TODO.md)** (поиск по «Duel» / режимам). --- -## 🎉 Итоги рефакторинга +## Чек-лист перед релизом -### Исправлено критичных багов: 8/8 ✅ -- Ghost UUID параметр -- ThreadLocalRandom вместо new Random() -- ConcurrentModificationException -- Сохранение всех лидербордов -- Двойной вызов cleanup -- System.err → Logger -- Логирование ошибок в disable() -- **SQL Injection полностью устранён** - -### Улучшения безопасности: 4/4 ✅ -- Rate limiting (уже был) -- Валидация конфигурации -- Fallback для схематиков -- Локализация всех строк - -### Оптимизация: 4/4 ✅ -- Оптимизация getPlayer() -- Константы вместо магических чисел -- Конфигурируемые шансы jump types -- PreparedStatement для SQL +- [ ] `./gradlew build` +- [ ] Старт сервера, `/parkour`, меню, один полный забег +- [ ] SQL: при проде выставить `LOPARKOUR_SQL_PASSWORD` на хосте +- [ ] Проверить миграцию старых `.dat` инвентарей при первом заходе игрока --- -## 🟢 Планово — дуэли -### DuelMode -- [ ] `DuelMode implements Mode` — режим дуэли -- [ ] `DuelSession extends Session` — два игрока, отдельные острова -- [ ] `/lp duel <игрок>` — вызов на дуэль, запрос истекает через 30 сек -- [ ] Форматы: **Race** (кто первый до N очков), **Survival** (3 жизни), **Time Attack** (60 сек кто больше) -- [ ] Scoreboard показывает счёт обоих в реальном времени -- [ ] ActionBar: `Opponent: 34 pts` во время игры -- [ ] При падении противника — уведомление -- [ ] Победитель получает награды через систему `Rewards` -- [ ] Лидерборд дуэлей (W/L) -- [ ] Кнопка реванша после окончания - ---- - -## ✅ Уже исправлено - -- ✅ BOM в `ParkourGenerator.java` ломает компиляцию -- ✅ `generator.score` → `generator.state.score` -- ✅ `ParkourUser.joinCount` удалён -- ✅ `PlayerSettingsManager.OptionContainer` не public -- ✅ `Option.PARTICLE_DATA.data()` не существует -- ✅ `ParticleUtil.circle()` неправильные параметры -- ✅ `PotionEffectType.JUMP` и `SLOW` удалены в Paper 1.20.5+ -- ✅ `ElytraMode` создаёт обычный `ParkourGenerator` -- ✅ `SpeedrunMode`: таймеры `runLater` продолжают тикать после `reset()` -- ✅ `HardcoreMode`: `collectedRewards` не очищается при падении -- ✅ `JumpValidator` создан но нигде не вызывается -- ✅ `JumpType` enum создан но не интегрирован в генератор -- ✅ `ConfigUpdater` закомментирован -- ✅ `GravityShiftMode`: нет визуала при смене эффекта -- ✅ Ghost система не интегрирована -- ✅ Удалить мёртвые stub-файлы -- ✅ ElytraMode: кольца из партиклов + детекция пролёта -- ✅ ElytraMode: cooldown на фейерверки -- ✅ RaceMode -- ✅ CoopMode -- ✅ Реорганизация `generator/` по подпакетам -- ✅ SQL Injection исправлен (PreparedStatement) -- ✅ PreparedStatement leak исправлен (try-with-resources) -- ✅ Scheduler Task Leak в SpeedrunMode исправлен -- ✅ Удалены GravityShiftMode и HardcoreMode (запутанные режимы) -- ✅ Предметы в хотбаре вместо инвентаря -- ✅ Схематики отключены из генерации -- ✅ Сложность генерации сбалансирована -- ✅ Ограничения после специальных блоков (слэбы, заборы, панели) -- ✅ Clamp значений height/distance для безопасности -- ✅ Fallback в JumpOffsetGenerator -- ✅ Локали для race и coop режимов -- ✅ Конфиг для race и coop режимов -- ✅ Fallback для first-block-material в Island.java -- ✅ Ghost файлы по UUID вместо имени (частично - нужно обновить вызовы) - ---- - -## 🎮 Чек-лист перед релизом - -- [x] `./gradlew build` без ошибок -- [x] Все критичные баги исправлены (пункты 1-8) -- [x] Все улучшения безопасности (пункты 9-12) -- [x] Все оптимизации (пункты 13-16) -- [x] SQL Injection полностью устранён -- [x] Версия обновлена до 1.3.1 -- [x] CHANGELOG.md создан -- [x] README.md обновлён -- [x] RELEASE_NOTES.md создан -- [x] Юнит-тесты добавлены (JumpValidator, GhostData) -- [x] Баги найденные тестами исправлены -- [x] Все тесты проходят успешно -- [ ] Запуск сервера без ошибок в консоли -- [ ] Паркур: прыжки, счёт, рекорд работают -- [ ] Scoreboard обновляется -- [ ] Все меню открываются -- [ ] SQL: данные сохраняются и читаются -- [ ] Партиклы и звук в каждом режиме -- [ ] Все режимы запускаются без ошибок (Default, Speedrun, Elytra, Race, Coop) -- [ ] Ghost система работает (запись и воспроизведение) -- [ ] Нет невозможных прыжков после специальных блоков -- [ ] Предметы в хотбаре (слоты 1,3,4,5,7) - ---- - -## 📊 Оценка из code review - -**Общая оценка: 7.5/10** - -**Сильные стороны:** -- Отличная структура пакетов -- Есть API для разработчиков -- Хорошая модульность (режимы, генератор, хранилище) -- Много систем (leaderboard, ghost, generator, schematics, menus, rewards) - - -**Что нужно исправить:** -- Критичные баги из таблицы выше -- Безопасность (rate limiting, валидация) -- Оптимизация (кеширование, константы) - -**Потенциал:** -Проект уровня реального Spigot ресурса (15-25k строк кода). При исправлении критичных багов можно выпускать в production. diff --git a/build.gradle.kts b/build.gradle.kts index 60a028b..0c678f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -77,12 +77,14 @@ dependencies { compileOnly("net.kyori:adventure-text-minimessage:4.14.0") // LoLib Core - implementation(files("libs/lolib-2.0.1.jar")) + implementation(files("libs/lolib-3.0.0.jar")) // Shaded dependencies implementation("io.papermc:paperlib:1.0.7") implementation("com.google.code.gson:gson:2.10.1") implementation("org.bstats:bstats-bukkit:3.2.1") + implementation("com.zaxxer:HikariCP:5.1.0") + implementation("com.mysql:mysql-connector-j:8.3.0") // Provided dependencies (plugins) compileOnly("me.clip:placeholderapi:2.11.6") @@ -110,9 +112,12 @@ tasks.shadowJar { relocate("io.papermc.lib", "dev.loki.loparkour.lib.paperlib") relocate("com.google.gson", "dev.loki.loparkour.lib.gson") relocate("org.bstats", "dev.loki.loparkour.lib.bstats") - - // Minimize jar size - minimize() + + // Minimize jar size (keep JDBC pool + driver intact) + minimize { + exclude(dependency("com.zaxxer:HikariCP:.*")) + exclude(dependency("com.mysql:mysql-connector-j:.*")) + } } tasks.named("build") { diff --git a/src/main/java/dev/loki/loparkour/LoParkour.java b/src/main/java/dev/loki/loparkour/LoParkour.java index 426652e..9740e8b 100644 --- a/src/main/java/dev/loki/loparkour/LoParkour.java +++ b/src/main/java/dev/loki/loparkour/LoParkour.java @@ -18,9 +18,9 @@ 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.Divider; 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; @@ -30,6 +30,7 @@ import org.bstats.charts.SingleLineChart; import org.jetbrains.annotations.Nullable; import java.io.File; +import java.util.ArrayList; /** * Main class of LoParkour @@ -171,7 +172,7 @@ public final class LoParkour extends LoPlugin { 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 SimplePie("locale_count", () -> Integer.toString(Locales.getLocaleCount()))); metrics.addCustomChart(new SingleLineChart("player_joins", () -> { int joins = UserRegistry.getJoinCount(); return joins; @@ -181,12 +182,30 @@ public final class LoParkour extends LoPlugin { @Override public void disable() { try { + // Clear all sessions BEFORE leaving players to prevent memory leaks + new ArrayList<>(Divider.sections.keySet()).forEach(session -> { + try { + session.onAllPlayersLeft(); + } catch (Exception e) { + getLogger().warning("Error cleaning up session: " + e.getMessage()); + } + }); + + // Remove all sessions from Divider + new ArrayList<>(Divider.sections.keySet()).forEach(Divider::remove); + + // Now leave all players for (ParkourUser user : ParkourUser.getUsers()) { ParkourUser.leave(user); } - // write all LoParkour gamemodes - Modes.DEFAULT.getLeaderboard().write(false); + // write all LoParkour gamemodes (may be null if enable failed) + if (Modes.DEFAULT != null && Modes.DEFAULT.getLeaderboard() != null) { + Modes.DEFAULT.getLeaderboard().write(false); + } + + // Clear rewards to prevent memory leaks + dev.loki.loparkour.reward.Rewards.clear(); Storage.close(); World.delete(); diff --git a/src/main/java/dev/loki/loparkour/LoParkourCommand.java b/src/main/java/dev/loki/loparkour/LoParkourCommand.java index 3ac2111..8ca653f 100644 --- a/src/main/java/dev/loki/loparkour/LoParkourCommand.java +++ b/src/main/java/dev/loki/loparkour/LoParkourCommand.java @@ -16,6 +16,7 @@ import dev.loki.loparkour.player.ParkourUser; import dev.loki.loparkour.session.Session; import dev.loki.loparkour.util.ColorUtil; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -28,13 +29,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.logging.Level; /** * Main command router for /LoParkour and /parkour. * Delegates to {@link PlayerCommandHandler}, {@link AdminCommandHandler}, * and {@link SchematicCommandHandler}. */ -@SuppressWarnings("deprecation") public class LoParkourCommand implements CommandExecutor, TabCompleter { private final PlayerCommandHandler player = new PlayerCommandHandler(); @@ -43,15 +44,23 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter { @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { - Player p = sender instanceof Player ? (Player) sender : null; + try { + Player p = sender instanceof Player ? (Player) sender : null; - switch (args.length) { - case 0 -> player.handleNoArgs(sender, p); - case 1 -> player.handle(args[0], sender, p); - case 2 -> handle2(args[0], args[1], sender, p); - case 3 -> handle3(args[0], args[1], args[2], sender, p); + switch (args.length) { + case 0 -> player.handleNoArgs(sender, p); + case 1 -> player.handle(args[0], sender, p); + case 2 -> handle2(args[0], args[1], sender, p); + case 3 -> handle3(args[0], args[1], args[2], sender, p); + default -> sender.sendMessage(ChatColor.GRAY + "Too many arguments. Try /parkour help"); + } + return true; + } catch (Throwable t) { + LoParkour.getPlugin().getLogger().log(Level.SEVERE, + "LoParkour command failed (" + label + " " + String.join(" ", args) + ")", t); + sender.sendMessage(ChatColor.RED + "LoParkour: command error — see server console."); + return true; } - return true; } // ── 2-arg routing ────────────────────────────────────────────────────────── @@ -108,7 +117,12 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter { if (user != null && user.session == session) return; if (session.isAcceptingPlayers()) { - ((MultiMode) session.generator.getMode()).join(p, session); + Mode sessionMode = session.generator.getMode(); + if (sessionMode instanceof MultiMode mm) { + mm.join(p, session); + } else { + Modes.SPECTATOR.create(p, session); + } } else { Modes.SPECTATOR.create(p, session); } @@ -153,7 +167,10 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter { completions.add("everyone"); ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName())); } else if (a1.equals("join") && ParkourOption.JOIN.mayPerform(sender)) { + Registry.getModes().forEach(m -> completions.add(m.getName())); ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName())); + } else if (a1.equals("leaderboard") && ParkourOption.LEADERBOARDS.mayPerform(sender)) { + Registry.getModes().forEach(m -> completions.add(m.getName())); } else if (a1.equals("schematic") && sender.hasPermission(ParkourOption.ADMIN.permission)) { completions.addAll(Arrays.asList("wand", "pos1", "pos2", "save", "paste", "list", "reload")); } else if ((a1.equals("forcejoin") || a1.equals("forceleave")) && sender.hasPermission(ParkourOption.ADMIN.permission)) { diff --git a/src/main/java/dev/loki/loparkour/api/ParkourAPI.java b/src/main/java/dev/loki/loparkour/api/ParkourAPI.java index d6a5840..ba303cc 100644 --- a/src/main/java/dev/loki/loparkour/api/ParkourAPI.java +++ b/src/main/java/dev/loki/loparkour/api/ParkourAPI.java @@ -17,7 +17,6 @@ import org.jetbrains.annotations.Nullable; *
  • For Schematics, please view {@link dev.efnilite.LoParkour.schematic.LPSchematic}
  • * */ -@SuppressWarnings("unused") public class ParkourAPI { private ParkourAPI() throws IllegalAccessException { diff --git a/src/main/java/dev/loki/loparkour/api/Registry.java b/src/main/java/dev/loki/loparkour/api/Registry.java index 81e537d..d622bed 100644 --- a/src/main/java/dev/loki/loparkour/api/Registry.java +++ b/src/main/java/dev/loki/loparkour/api/Registry.java @@ -1,6 +1,5 @@ package dev.loki.loparkour.api; -import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.mode.Mode; import dev.loki.loparkour.style.Style; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/dev/loki/loparkour/api/event/ParkourSchematicGenerateEvent.java b/src/main/java/dev/loki/loparkour/api/event/ParkourSchematicGenerateEvent.java index ea8ea90..67af157 100644 --- a/src/main/java/dev/loki/loparkour/api/event/ParkourSchematicGenerateEvent.java +++ b/src/main/java/dev/loki/loparkour/api/event/ParkourSchematicGenerateEvent.java @@ -17,11 +17,11 @@ public class ParkourSchematicGenerateEvent extends Event { private static final HandlerList HANDLERS = new HandlerList(); - public final dev.loki.loparkour.schematic.lpschem.LPSchematic schematic; + public final LPSchematic schematic; public final ParkourGenerator generator; public final ParkourPlayer player; - public ParkourSchematicGenerateEvent(dev.loki.loparkour.schematic.lpschem.LPSchematic schematic, ParkourGenerator generator, ParkourPlayer player) { + public ParkourSchematicGenerateEvent(LPSchematic schematic, ParkourGenerator generator, ParkourPlayer player) { this.schematic = schematic; this.generator = generator; this.player = player; diff --git a/src/main/java/dev/loki/loparkour/command/AdminCommandHandler.java b/src/main/java/dev/loki/loparkour/command/AdminCommandHandler.java index c159031..4d2e755 100644 --- a/src/main/java/dev/loki/loparkour/command/AdminCommandHandler.java +++ b/src/main/java/dev/loki/loparkour/command/AdminCommandHandler.java @@ -2,8 +2,6 @@ package dev.loki.loparkour.command; import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.api.Registry; -import dev.loki.loparkour.config.Locales; -import dev.loki.loparkour.leaderboard.Leaderboard; import dev.loki.loparkour.menu.ParkourOption; import dev.loki.loparkour.mode.Mode; import dev.loki.loparkour.mode.Modes; @@ -28,6 +26,8 @@ import java.util.UUID; */ public class AdminCommandHandler { + private static final String COOLDOWN_HINT = "Please wait before using this again."; + private final PlayerCommandHandler base; public AdminCommandHandler(PlayerCommandHandler base) { @@ -56,6 +56,8 @@ public class AdminCommandHandler { // ── forcejoin ────────────────────────────────────────────────────────────── private void handleForceJoin(String target, CommandSender sender) { + if (!base.cooldown(sender, "forcejoin", 2500, COOLDOWN_HINT)) return; + if (target.equalsIgnoreCase("everyone")) { Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p)); send(sender, LoParkour.PREFIX + "Force joined everyone!"); @@ -76,6 +78,8 @@ public class AdminCommandHandler { // ── forceleave ───────────────────────────────────────────────────────────── private void handleForceLeave(String target, CommandSender sender) { + if (!base.cooldown(sender, "forceleave", 2500, COOLDOWN_HINT)) return; + if (target.equalsIgnoreCase("everyone")) { ParkourPlayer.getPlayers().forEach(ParkourUser::leave); send(sender, LoParkour.PREFIX + "Force kicked everyone!"); @@ -91,7 +95,7 @@ public class AdminCommandHandler { // ── reset ────────────────────────────────────────────────────────────────── private void handleReset(String target, CommandSender sender) { - if (!base.cooldown(sender, "reset", 2500)) return; + if (!base.cooldown(sender, "reset", 2500, COOLDOWN_HINT)) return; if (target.equalsIgnoreCase("everyone")) { Registry.getModes().stream() @@ -116,7 +120,7 @@ public class AdminCommandHandler { // ── recoverinventory ─────────────────────────────────────────────────────── private void handleRecoverInventory(String target, CommandSender sender) { - if (!base.cooldown(sender, "recoverinventory", 2500)) return; + if (!base.cooldown(sender, "recoverinventory", 2500, COOLDOWN_HINT)) return; Player other = Bukkit.getPlayer(target); if (other == null) { send(sender, LoParkour.PREFIX + "Player not online!"); return; } @@ -142,10 +146,17 @@ public class AdminCommandHandler { .orElse(null); } + @SuppressWarnings("deprecation") // Bukkit: name-based OfflinePlayer lookup (admin-only) private UUID resolveUUID(String input) { Player online = Bukkit.getPlayerExact(input); if (online != null) return online.getUniqueId(); - if (input.contains("-")) return UUID.fromString(input); + if (input.contains("-")) { + try { + return UUID.fromString(input); + } catch (IllegalArgumentException ignored) { + // fall through to offline lookup by name + } + } return Bukkit.getOfflinePlayer(input).getUniqueId(); } diff --git a/src/main/java/dev/loki/loparkour/command/PlayerCommandHandler.java b/src/main/java/dev/loki/loparkour/command/PlayerCommandHandler.java index 3359b02..f11ba92 100644 --- a/src/main/java/dev/loki/loparkour/command/PlayerCommandHandler.java +++ b/src/main/java/dev/loki/loparkour/command/PlayerCommandHandler.java @@ -60,7 +60,13 @@ public class PlayerCommandHandler { case "menu", "main" -> { if (ParkourOption.MAIN.mayPerform(player)) Menus.MAIN.open(player); } - case "leaderboard" -> player.performCommand("LoParkour leaderboard invalid"); + case "leaderboard" -> { + if (!ParkourOption.LEADERBOARDS.mayPerform(player)) { + send(sender, Locales.getString(player, "other.no_do")); + return; + } + Menus.LEADERBOARDS.open(player); + } case "schematic" -> { if (!player.hasPermission(ParkourOption.ADMIN.permission)) { send(sender, Locales.getString(player, "other.no_do")); @@ -107,10 +113,22 @@ public class PlayerCommandHandler { } public boolean cooldown(CommandSender sender, String key, long millis) { + return cooldown(sender, key, millis, null); + } + + /** + * @param whenBlocked message sent (MiniMessage color) if still on cooldown; may be null for silent block + */ + public boolean cooldown(CommandSender sender, String key, long millis, @Nullable String whenBlocked) { String fullKey = sender.getName() + ":" + key; long now = System.currentTimeMillis(); Long last = cooldowns.get(fullKey); - if (last != null && now - last < millis) return false; + if (last != null && now - last < millis) { + if (whenBlocked != null) { + send(sender, whenBlocked); + } + return false; + } cooldowns.put(fullKey, now); return true; } diff --git a/src/main/java/dev/loki/loparkour/config/Config.java b/src/main/java/dev/loki/loparkour/config/Config.java index a0cf675..729c18e 100644 --- a/src/main/java/dev/loki/loparkour/config/Config.java +++ b/src/main/java/dev/loki/loparkour/config/Config.java @@ -1,22 +1,15 @@ package dev.loki.loparkour.config; -import dev.loki.loparkour.LoParkour; -import dev.loki.loparkour.reward.Rewards; -import dev.loki.loparkour.schematic.Schematics; - -import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.configuration.file.YamlConfiguration; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.File; -import java.util.ArrayList; import java.util.List; -import java.util.NoSuchElementException; /** - * Config management class. + * Refactored Config enum using composition pattern. + * Delegates functionality to specialized components. */ public enum Config { @@ -25,252 +18,102 @@ public enum Config { REWARDS("rewards-v2.yml", List.of("score-rewards", "interval-rewards", "one-time-rewards")), SCHEMATICS("schematics/schematics.yml", List.of("difficulty")); - /** - * The path to this file, incl. plugin folder. - * Not final — resolved lazily on first reload() call after plugin instance is set. - */ public File path; - /** - * The name of this file, e.g. config.yml - */ public final String fileName; - /** - * The sections in the file that will be ignored when updating the keys. - */ public final List ignoredSections; - /** - * The {@link FileConfiguration} instance associated with this config file. - */ public FileConfiguration fileConfiguration; + + private ConfigAccessor accessor; Config(String fileName, @Nullable List ignoredSections) { this.fileName = fileName; this.ignoredSections = ignoredSections; - // path resolved lazily via getPath() — do NOT call LoParkour.getInFolder() here, - // because enum constants are initialized before onEnable() sets the instance. - this.path = null; // placeholder; real path resolved on first access + this.path = null; // Resolved lazily } - - /** - * Reloads all config files. - * On first call (initialLoad=true) also resolves file paths and saves defaults. + * Reload all configuration files. */ public static void reload(boolean initialLoad) { + ConfigLoader.reloadAllConfigs(initialLoad); + + // Update accessors for all configs for (Config config : values()) { - if (initialLoad) { - // Resolve path now that instance is guaranteed to be set - config.path = LoParkour.getInFolder(config.fileName); - if (!config.path.exists()) { - LoParkour.getPlugin().saveResource(config.fileName, false); - } - config.update(); + if (config.fileConfiguration != null) { + config.accessor = new ConfigAccessor(config.fileConfiguration, config.fileName); } - config.load(); } - // Validate configuration values - validateConfigs(); - - // read config stuff - Rewards.init(); - Locales.init(); - Schematics.init(); - Option.init(initialLoad); + // Now that accessors exist, initialize systems that depend on Config getters + ConfigLoader.initializeDependentSystems(initialLoad); } /** - * Validates critical configuration values to prevent crashes and unexpected behavior. - */ - private static void validateConfigs() { - var logger = LoParkour.getPlugin().getLogger(); - - // Validate border-size - if (CONFIG.isPath("world.border-size")) { - int borderSize = CONFIG.getInt("world.border-size"); - if (borderSize <= 0) { - logger.severe("Invalid config: world.border-size must be > 0, found: " + borderSize); - logger.severe("Using default value: 1000"); - } - } - - // Validate world dimensions - if (CONFIG.isPath("world.max-y") && CONFIG.isPath("world.min-y")) { - int maxY = CONFIG.getInt("world.max-y"); - int minY = CONFIG.getInt("world.min-y"); - if (maxY <= minY) { - logger.severe("Invalid config: world.max-y (" + maxY + ") must be > world.min-y (" + minY + ")"); - logger.severe("Using default values: max-y=320, min-y=-64"); - } - } - - // Validate section dimensions - if (CONFIG.isPath("world.section.width") && CONFIG.isPath("world.section.length")) { - int width = CONFIG.getInt("world.section.width"); - int length = CONFIG.getInt("world.section.length"); - if (width <= 0 || length <= 0) { - logger.severe("Invalid config: world.section dimensions must be > 0, found: width=" + width + ", length=" + length); - logger.severe("Using default values: width=50, length=50"); - } - } - - // Validate generation chances (must sum to reasonable values) - if (GENERATION.isPath("chances.height")) { - double sum = GENERATION.getDouble("chances.height.1") + - GENERATION.getDouble("chances.height.0") + - GENERATION.getDouble("chances.height.-1") + - GENERATION.getDouble("chances.height.-2"); - if (sum <= 0) { - logger.severe("Invalid config: generation height chances sum to " + sum + ", must be > 0"); - logger.severe("Check generation.yml chances.height section"); - } - } - - // Validate ghost mode settings - if (CONFIG.isPath("ghost-mode.show-top")) { - int showTop = CONFIG.getInt("ghost-mode.show-top"); - if (showTop < 0 || showTop > 10) { - logger.warning("Config: ghost-mode.show-top should be between 0-10, found: " + showTop); - } - } - - // Validate cleanup settings - if (GENERATION.isPath("advanced.cleanup-distance")) { - int cleanupDistance = GENERATION.getInt("advanced.cleanup-distance"); - if (cleanupDistance <= 0) { - logger.severe("Invalid config: advanced.cleanup-distance must be > 0, found: " + cleanupDistance); - logger.severe("Using default value: 100"); - } - } - } - - /** - * Loads the file from disk. + * Load this configuration file from disk. */ public void load() { - if (path == null) return; // not yet initialized, skip - this.fileConfiguration = YamlConfiguration.loadConfiguration(path); + if (path == null) return; + + this.fileConfiguration = ConfigLoader.loadConfig(path); + this.accessor = new ConfigAccessor(fileConfiguration, fileName); } /** - * Updates the file so all keys are present. - * Any key that exists in the bundled default but is missing on disk - * will be appended to the correct section automatically. + * Update this configuration file with new keys. */ public void update() { - try { - ConfigUpdater.update(LoParkour.getPlugin(), fileName, path, ignoredSections); - } catch (Exception ex) { - LoParkour.getPlugin().getLogger().log(java.util.logging.Level.SEVERE, - "Error while trying to update config file: " + fileName, ex); + if (path != null) { + ConfigLoader.updateConfig(fileName, path, ignoredSections); } } - /** - * @param path The path. - * @return True when path exists, false if not. - */ + // Delegate all accessor methods to ConfigAccessor + public boolean isPath(@NotNull String path) { - return fileConfiguration.isSet(path); + return accessor != null && accessor.isPath(path); } - /** - * @param path The path. - * @return The value at path. - */ public Object get(@NotNull String path) { - check(path); - - return fileConfiguration.get(path); + return accessor.get(path); } - /** - * @param path The path. - * @return The boolean value at path. - */ public boolean getBoolean(@NotNull String path) { - check(path); - - return fileConfiguration.getBoolean(path); + return accessor.getBoolean(path); + } + + public boolean getBoolean(@NotNull String path, boolean defaultValue) { + return accessor != null ? accessor.getBoolean(path, defaultValue) : defaultValue; } - /** - * @param path The path. - * @return The int value at path. - */ public int getInt(@NotNull String path) { - check(path); - - return fileConfiguration.getInt(path); + return accessor.getInt(path); + } + + public int getInt(@NotNull String path, int defaultValue) { + return accessor != null ? accessor.getInt(path, defaultValue) : defaultValue; } - /** - * @param path The path. - * @return The double value at path. - */ public double getDouble(@NotNull String path) { - check(path); - - return fileConfiguration.getDouble(path); + return accessor.getDouble(path); } - /** - * @param path The path. - * @return The String value at path. - */ @NotNull public String getString(@NotNull String path) { - check(path); - - return fileConfiguration.getString(path, ""); + return accessor.getString(path); } - /** - * @param path The path. - * @return The String list value at path. - */ @NotNull public List getStringList(@NotNull String path) { - check(path); - - return fileConfiguration.getStringList(path); + return accessor.getStringList(path); } - /** - * @param path The path. - * @return The int list value at path. - */ @NotNull public List getIntList(@NotNull String path) { - check(path); - - return fileConfiguration.getIntegerList(path); + return accessor.getIntList(path); } - /** - * @param path The path. - * @param deep Whether search should include children of children as well. - * @return The children nodes from path. - */ @NotNull public List getChildren(@NotNull String path, boolean... deep) { - check(path); - - ConfigurationSection section = fileConfiguration.getConfigurationSection(path); - - if (section == null) { - return new ArrayList<>(); - } - - boolean isDeep = deep.length > 0 && deep[0]; - return new ArrayList<>(section.getKeys(isDeep)); + return accessor.getChildren(path, deep); } - - // checks if the specified path exists to avoid developer error - private void check(@NotNull String path) { - if (!isPath(path)) { - throw new NoSuchElementException("Unknown path %s in %s".formatted(path, fileName)); - } - } -} +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/config/ConfigAccessor.java b/src/main/java/dev/loki/loparkour/config/ConfigAccessor.java new file mode 100644 index 0000000..f921a1e --- /dev/null +++ b/src/main/java/dev/loki/loparkour/config/ConfigAccessor.java @@ -0,0 +1,141 @@ +package dev.loki.loparkour.config; + +import dev.loki.loparkour.LoParkour; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Provides safe access to configuration values with validation. + */ +public class ConfigAccessor { + + private final FileConfiguration config; + private final String fileName; + private final Set warnedMissingPaths = ConcurrentHashMap.newKeySet(); + + public ConfigAccessor(@NotNull FileConfiguration config, @NotNull String fileName) { + this.config = config; + this.fileName = fileName; + } + + /** + * Check if path exists in configuration. + */ + public boolean isPath(@NotNull String path) { + return config.isSet(path); + } + + /** + * Get raw object value at path. + */ + public Object get(@NotNull String path) { + warnIfMissing(path); + return config.get(path); + } + + /** + * Get boolean value at path. + */ + public boolean getBoolean(@NotNull String path) { + warnIfMissing(path); + return config.getBoolean(path, false); + } + + /** + * Boolean at path, or default when the path is absent (no missing-path warning). + */ + public boolean getBoolean(@NotNull String path, boolean defaultValue) { + if (!isPath(path)) { + return defaultValue; + } + return config.getBoolean(path, defaultValue); + } + + /** + * Get integer value at path. + */ + public int getInt(@NotNull String path) { + warnIfMissing(path); + return config.getInt(path, 0); + } + + /** + * Get integer value at path with default. + */ + public int getInt(@NotNull String path, int defaultValue) { + if (!isPath(path)) { + return defaultValue; + } + return config.getInt(path, defaultValue); + } + + /** + * Get double value at path. + */ + public double getDouble(@NotNull String path) { + warnIfMissing(path); + return config.getDouble(path, 0.0); + } + + /** + * Get string value at path. + */ + @NotNull + public String getString(@NotNull String path) { + warnIfMissing(path); + return config.getString(path, ""); + } + + /** + * Get string list at path. + */ + @NotNull + public List getStringList(@NotNull String path) { + warnIfMissing(path); + return config.getStringList(path); + } + + /** + * Get integer list at path. + */ + @NotNull + public List getIntList(@NotNull String path) { + warnIfMissing(path); + return config.getIntegerList(path); + } + + /** + * Get child keys from configuration section. + */ + @NotNull + public List getChildren(@NotNull String path, boolean... deep) { + if (!isPath(path)) { + warnIfMissing(path); + return new ArrayList<>(); + } + + ConfigurationSection section = config.getConfigurationSection(path); + if (section == null) { + return new ArrayList<>(); + } + + boolean isDeep = deep.length > 0 && deep[0]; + return new ArrayList<>(section.getKeys(isDeep)); + } + + private void warnIfMissing(@NotNull String path) { + if (isPath(path)) { + return; + } + if (warnedMissingPaths.add(path)) { + LoParkour.getPlugin().getLogger().warning( + "Missing config path '" + path + "' in " + fileName + ". Using a safe default value."); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/config/ConfigLoader.java b/src/main/java/dev/loki/loparkour/config/ConfigLoader.java new file mode 100644 index 0000000..899739c --- /dev/null +++ b/src/main/java/dev/loki/loparkour/config/ConfigLoader.java @@ -0,0 +1,87 @@ +package dev.loki.loparkour.config; + +import dev.loki.loparkour.LoParkour; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.util.List; +import java.util.logging.Logger; + +/** + * Handles loading, updating, and basic validation of configuration files. + */ +public class ConfigLoader { + + @NotNull + public static FileConfiguration loadConfig(@NotNull File configFile) { + return YamlConfiguration.loadConfiguration(configFile); + } + + @NotNull + public static File initializeConfigFile(@NotNull String fileName) { + File configFile = LoParkour.getInFolder(fileName); + if (!configFile.exists()) { + LoParkour.getPlugin().saveResource(fileName, false); + } + return configFile; + } + + public static void updateConfig(@NotNull String fileName, @NotNull File configFile, @Nullable List ignoredSections) { + try { + var plugin = LoParkour.getPlugin(); + var resourceStream = plugin.getResource(fileName); + if (resourceStream != null) { + ConfigUpdater.update(configFile, resourceStream, "1.0", ignoredSections, plugin.getLogger()); + } + } catch (Exception ex) { + LoParkour.getPlugin().getLogger().log(java.util.logging.Level.SEVERE, + "Error while trying to update config file: " + fileName, ex); + } + } + + public static void reloadAllConfigs(boolean initialLoad) { + for (Config config : Config.values()) { + if (initialLoad) { + config.path = initializeConfigFile(config.fileName); + updateConfig(config.fileName, config.path, config.ignoredSections); + } + config.fileConfiguration = loadConfig(config.path); + } + + validateConfigs(); + } + + public static void initializeDependentSystems(boolean firstLoad) { + dev.loki.loparkour.reward.Rewards.init(); + Locales.init(); + dev.loki.loparkour.schematic.Schematics.init(); + Option.init(firstLoad); + } + + // ── inline validation (was ConfigValidatorRefactored) ───────────────────── + + private static void validateConfigs() { + Logger log = LoParkour.getPlugin().getLogger(); + + if (Config.CONFIG.isPath("world.max-y") && Config.CONFIG.isPath("world.min-y")) { + int maxY = Config.CONFIG.getInt("world.max-y"); + int minY = Config.CONFIG.getInt("world.min-y"); + if (maxY <= minY) { + log.severe("Invalid config: world.max-y (%d) must be > world.min-y (%d)".formatted(maxY, minY)); + } + } + + if (Config.GENERATION.isPath("generation.normal.height")) { + double sum = Config.GENERATION.getDouble("generation.normal.height.1") + + Config.GENERATION.getDouble("generation.normal.height.0") + + Config.GENERATION.getDouble("generation.normal.height.-1") + + Config.GENERATION.getDouble("generation.normal.height.-2"); + if (sum <= 0) { + log.severe("Invalid config: generation height chances sum to %.1f, must be > 0".formatted(sum)); + } + } + } +} diff --git a/src/main/java/dev/loki/loparkour/config/ConfigUpdater.java b/src/main/java/dev/loki/loparkour/config/ConfigUpdater.java index 0e24566..dd7d3af 100644 --- a/src/main/java/dev/loki/loparkour/config/ConfigUpdater.java +++ b/src/main/java/dev/loki/loparkour/config/ConfigUpdater.java @@ -1,287 +1,197 @@ package dev.loki.loparkour.config; -import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.*; -import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.*; -import java.util.logging.Level; +import java.util.logging.Logger; /** - * Automatically adds missing keys from the default (bundled) config - * into the player's config file on disk, preserving all existing values, - * comments, and blank lines. - * - *

    Algorithm: - *

      - *
    1. Load the default config from the jar as a list of raw lines.
    2. - *
    3. Load the on-disk config as a flat key→value map.
    4. - *
    5. Walk the default lines; for every key that is missing on disk, - * emit the default line (with its comment header) into the output.
    6. - *
    7. Rewrite the on-disk file with the merged result.
    8. - *
    - * - * Sections listed in {@code ignoredSections} are never touched — their - * default content is never injected (user manages them freely). + * Merges a resource-template YAML with the user's existing file, + * preserving user values while adding any new keys from the template. + * Uses full YAML paths (e.g. "sql.enabled") to avoid key collisions. */ -public final class ConfigUpdater { +public class ConfigUpdater { - private ConfigUpdater() {} + private final Logger logger; + + private ConfigUpdater(@NotNull Logger logger) { + this.logger = logger; + } - /** - * Updates {@code diskFile} by injecting any keys that exist in the - * bundled default but are absent on disk. - * - * @param plugin the plugin (used to read the bundled resource) - * @param resourceName path inside the jar, e.g. {@code "config.yml"} - * @param diskFile the on-disk file to update - * @param ignoredSections top-level sections whose keys should never be added - * (pass {@code null} or empty list to ignore nothing) - */ public static void update( - @NotNull Plugin plugin, - @NotNull String resourceName, - @NotNull File diskFile, - @Nullable List ignoredSections - ) throws IOException { + @NotNull File configFile, + @NotNull InputStream templateStream, + @NotNull String expectedVersion, + @Nullable List ignoredKeys, + @NotNull Logger logger) throws IOException { - List defaultLines = readResource(plugin, resourceName); - if (defaultLines == null) { - plugin.getLogger().warning("[ConfigUpdater] Resource not found in jar: " + resourceName); - return; - } + new ConfigUpdater(logger).performUpdate(configFile, templateStream, ignoredKeys); + } - // Flat key set of what already exists on disk (dot-separated paths) - Set diskKeys = flatKeys(diskFile); + // ── core ────────────────────────────────────────────────────────────────── - // Ignored section prefixes (e.g. "styles" → skip any path starting with "styles.") - Set ignored = normalizedIgnored(ignoredSections); + private void performUpdate( + @NotNull File configFile, + @NotNull InputStream templateStream, + @Nullable List ignoredKeys) throws IOException { - List diskLines = readLines(diskFile); - List outputLines = merge(defaultLines, diskLines, diskKeys, ignored); - - // Only write if something actually changed - if (!outputLines.equals(diskLines)) { - writeLines(diskFile, outputLines); - plugin.getLogger().info("[ConfigUpdater] Updated " + resourceName - + " — added " + countNew(diskLines, outputLines) + " new key(s)."); + File backup = createBackup(configFile); + try { + List merged = merge(templateStream, configFile, ignoredKeys); + Files.write(configFile.toPath(), merged); + logger.info("Configuration file synced: " + configFile.getName()); + } catch (Exception e) { + if (backup != null && backup.exists()) { + Files.copy(backup.toPath(), configFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + logger.severe("Config sync failed, restored backup: " + e.getMessage()); + } + throw e; } } - // ── Merge ───────────────────────────────────────────────────────────────── + // ── merge ───────────────────────────────────────────────────────────────── - /** - * Merges default lines into disk lines. - * - *

    Strategy: walk disk lines first, emit them as-is. Then walk default - * lines; for each key block (comment + key line) whose key is absent on - * disk and not ignored, append it at the correct indentation level. - */ - private static List merge( - List defaultLines, - List diskLines, - Set diskKeys, - Set ignored - ) { - // We need to insert missing keys at the right position. - // Build a copy of disk lines we will extend. - List result = new ArrayList<>(diskLines); + @NotNull + private List merge( + @NotNull InputStream templateStream, + @NotNull File existingFile, + @Nullable List ignoredKeys) throws IOException { - // Collect blocks from the default file: each block = list of comment - // lines + the key line itself. We track the "path stack" to know the - // full dotted path of every key. - Deque pathStack = new ArrayDeque<>(); - List pendingComments = new ArrayList<>(); + List templateLines = readLines(templateStream); + List existingLines = existingFile.exists() ? readLines(existingFile) : List.of(); - for (int i = 0; i < defaultLines.size(); i++) { - String raw = defaultLines.get(i); - String trimmed = raw.stripLeading(); + Set ignored = normalizeIgnored(ignoredKeys); + Map existingValues = extractFullPathValues(existingLines); - // Blank line → flush pending comments, keep as separator - if (trimmed.isBlank()) { - pendingComments.add(raw); + List result = new ArrayList<>(); + Deque stack = new ArrayDeque<>(); + int prevIndent = -1; + + for (String line : templateLines) { + String trimmed = line.trim(); + + if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) { + result.add(line); continue; } - // Comment line → accumulate - if (trimmed.startsWith("#")) { - pendingComments.add(raw); - continue; + int colon = trimmed.indexOf(':'); + if (colon == -1) { result.add(line); continue; } + + String leaf = trimmed.substring(0, colon).trim(); + String after = trimmed.substring(colon + 1).trim(); + int indent = indentOf(line); + + while (prevIndent >= 0 && indent <= prevIndent) { + if (!stack.isEmpty()) stack.pop(); + prevIndent -= 2; } - // Key line - int indent = raw.length() - trimmed.length(); - int depth = indent / 2; // assume 2-space indentation + String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf; + boolean section = after.isEmpty() || after.startsWith("#"); - // Pop stack back to current depth - while (pathStack.size() > depth) pathStack.pollLast(); - - // Parse key name (everything before ':') - String keyPart = trimmed.split(":")[0].trim(); - pathStack.addLast(keyPart); - String fullPath = String.join(".", pathStack); - - boolean isSection = trimmed.endsWith(":") || trimmed.matches(".*:\\s*$"); - boolean isIgnored = isIgnored(fullPath, ignored); - - if (!isIgnored && !diskKeys.contains(fullPath) && !isSection) { - // This key is missing on disk — inject it - // Find the best insertion point: after the last line that - // belongs to the parent section on disk - int insertAt = findInsertionPoint(result, pathStack, depth); - - // Insert blank separator + comments + key line - List block = new ArrayList<>(); - if (insertAt == result.size() || !result.get(Math.max(0, insertAt - 1)).isBlank()) { - block.add(""); - } - block.addAll(pendingComments); - block.add(raw); - result.addAll(insertAt, block); + if (section) { + stack.push(leaf); + prevIndent = indent; + result.add(line); + } else if (isIgnored(fullPath, ignored)) { + result.add(line); + prevIndent = indent; + } else { + String existing = existingValues.get(fullPath); + result.add(existing != null + ? line.substring(0, line.indexOf(':') + 1) + " " + existing + : line); + prevIndent = indent; } - - // Clear pending comments regardless - pendingComments.clear(); } - return result; } - /** - * Finds the line index where a missing key should be inserted. - * Inserts after the last line of its parent section. - */ - private static int findInsertionPoint(List lines, Deque pathStack, int depth) { - if (depth == 0) { - // Top-level key → append at end - return lines.size(); - } + @NotNull + private Map extractFullPathValues(@NotNull List lines) { + Map values = new LinkedHashMap<>(); + Deque stack = new ArrayDeque<>(); + int prevIndent = -1; - // Parent path - List parts = new ArrayList<>(pathStack); - String parentKey = parts.get(depth - 1); - int parentIndent = (depth - 1) * 2; + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) continue; - // Find the parent key line in the result - int parentLine = -1; - for (int i = 0; i < lines.size(); i++) { - String l = lines.get(i); - int ind = l.length() - l.stripLeading().length(); - if (ind == parentIndent && l.stripLeading().startsWith(parentKey + ":")) { - parentLine = i; - break; + int colon = trimmed.indexOf(':'); + if (colon == -1) continue; + + String leaf = trimmed.substring(0, colon).trim(); + String after = trimmed.substring(colon + 1).trim(); + int indent = indentOf(line); + + while (prevIndent >= 0 && indent <= prevIndent) { + if (!stack.isEmpty()) stack.pop(); + prevIndent -= 2; } + + String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf; + + if (after.isEmpty() || after.startsWith("#")) { + stack.push(leaf); + } else { + values.put(fullPath, after); + } + prevIndent = indent; } - - if (parentLine < 0) return lines.size(); - - // Find end of parent section: next line with same or lower indent that isn't blank/comment - for (int i = parentLine + 1; i < lines.size(); i++) { - String l = lines.get(i); - if (l.isBlank() || l.stripLeading().startsWith("#")) continue; - int ind = l.length() - l.stripLeading().length(); - if (ind <= parentIndent) return i; - } - - return lines.size(); + return values; } - // ── IO helpers ───────────────────────────────────────────────────────────── + // ── helpers ─────────────────────────────────────────────────────────────── + + private static int indentOf(@NotNull String line) { + int i = 0; + while (i < line.length() && line.charAt(i) == ' ') i++; + return i; + } - /** Reads a resource from the jar as a list of lines. Returns null if not found. */ @Nullable - private static List readResource(Plugin plugin, String name) { - try (InputStream in = plugin.getResource(name)) { - if (in == null) return null; - return readLines(in); - } catch (IOException ex) { - plugin.getLogger().log(Level.SEVERE, "Failed to read resource: " + name, ex); + private File createBackup(@NotNull File file) { + if (!file.exists()) return null; + try { + File bak = new File(file.getParent(), file.getName() + ".bak"); + Files.copy(file.toPath(), bak.toPath(), StandardCopyOption.REPLACE_EXISTING); + return bak; + } catch (IOException e) { + logger.warning("Failed to create backup: " + e.getMessage()); return null; } } - private static List readLines(File file) throws IOException { - try (BufferedReader r = new BufferedReader( - new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) { - return readLines(r); + @NotNull + private static List readLines(@NotNull InputStream is) throws IOException { + try (var r = new BufferedReader(new InputStreamReader(is))) { + return r.lines().toList(); } } - private static List readLines(InputStream in) throws IOException { - try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { - return readLines(r); - } + @NotNull + private static List readLines(@NotNull File file) throws IOException { + return Files.readAllLines(file.toPath()); } - private static List readLines(BufferedReader reader) throws IOException { - List lines = new ArrayList<>(); - String line; - while ((line = reader.readLine()) != null) lines.add(line); - return lines; + @NotNull + private static Set normalizeIgnored(@Nullable List keys) { + if (keys == null) return Set.of(); + Set s = new HashSet<>(); + for (String k : keys) s.add(k.trim().toLowerCase()); + return s; } - private static void writeLines(File file, List lines) throws IOException { - try (BufferedWriter w = new BufferedWriter( - new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) { - for (int i = 0; i < lines.size(); i++) { - w.write(lines.get(i)); - if (i < lines.size() - 1) w.newLine(); - } - } - } - - // ── Key extraction ───────────────────────────────────────────────────────── - - /** - * Returns the flat set of dotted-path keys present in the file. - * Only leaf keys (not sections) are returned. - */ - private static Set flatKeys(File file) throws IOException { - Set keys = new LinkedHashSet<>(); - Deque stack = new ArrayDeque<>(); - - List lines = readLines(file); - for (String raw : lines) { - String trimmed = raw.stripLeading(); - if (trimmed.isBlank() || trimmed.startsWith("#")) continue; - - int indent = raw.length() - trimmed.length(); - int depth = indent / 2; - - while (stack.size() > depth) stack.pollLast(); - - if (!trimmed.contains(":")) continue; - String keyPart = trimmed.split(":")[0].trim(); - stack.addLast(keyPart); - - boolean isSection = trimmed.matches("[^:]+:\\s*$"); - if (!isSection) { - keys.add(String.join(".", stack)); - } - } - return keys; - } - - // ── Helpers ──────────────────────────────────────────────────────────────── - - private static Set normalizedIgnored(@Nullable List list) { - if (list == null || list.isEmpty()) return Collections.emptySet(); - Set result = new LinkedHashSet<>(); - for (String s : list) result.add(s.toLowerCase(Locale.ROOT).trim()); - return result; - } - - private static boolean isIgnored(String fullPath, Set ignored) { - String lower = fullPath.toLowerCase(Locale.ROOT); - for (String prefix : ignored) { - if (lower.equals(prefix) || lower.startsWith(prefix + ".")) return true; + private static boolean isIgnored(@NotNull String fullPath, @NotNull Set ignored) { + String lower = fullPath.toLowerCase(); + for (String ign : ignored) { + if (lower.equals(ign) || lower.startsWith(ign + ".")) return true; } return false; } - - private static int countNew(List before, List after) { - return Math.max(0, after.size() - before.size()); - } } diff --git a/src/main/java/dev/loki/loparkour/config/GenerationOptions.java b/src/main/java/dev/loki/loparkour/config/GenerationOptions.java deleted file mode 100644 index 3f123b6..0000000 --- a/src/main/java/dev/loki/loparkour/config/GenerationOptions.java +++ /dev/null @@ -1,117 +0,0 @@ -package dev.loki.loparkour.config; - -import dev.loki.loparkour.LoParkour; - -/** - * Generation and jump-validation settings. Populated by {@link Option#init}. - */ -public final class GenerationOptions { - - // Block type chances - public static double TYPE_NORMAL; - public static double TYPE_SPECIAL; - public static double TYPE_SCHEMATICS; - - // Special block chances - public static double SPECIAL_ICE; - public static double SPECIAL_SLAB; - public static double SPECIAL_PANE; - public static double SPECIAL_FENCE; - - // Distance weights - public static double NORMAL_DISTANCE_1; - public static double NORMAL_DISTANCE_2; - public static double NORMAL_DISTANCE_3; - public static double NORMAL_DISTANCE_4; - - // Height weights - public static double NORMAL_HEIGHT_1; - public static double NORMAL_HEIGHT_0; - public static double NORMAL_HEIGHT_NEG1; - public static double NORMAL_HEIGHT_NEG2; - - // World bounds - public static int MAX_Y; - public static int MIN_Y; - - // Jump validation - public static boolean JUMP_VALIDATION_ENABLED; - public static double MAX_JUMP_DISTANCE; - public static double MAX_HORIZONTAL_DISTANCE; - public static double MAX_VERTICAL_UP; - public static double MAX_VERTICAL_DOWN; - - // Jump types - public static boolean JUMP_TYPES_ENABLED; - public static java.util.Map JUMP_TYPE_ENABLED; - public static java.util.Map JUMP_TYPE_CHANCE; - - // Memory optimisation - public static int BLOCK_CLEANUP_DISTANCE; - public static int CLEANUP_INTERVAL; - - // Ghost mode - public static boolean GHOST_MODE_ENABLED; - public static int GHOST_SHOW_TOP; - public static double GHOST_TRANSPARENCY; - - static void init() { - // Jump validation - JUMP_VALIDATION_ENABLED = Config.CONFIG.isPath("jump-validation.enabled") && Config.CONFIG.getBoolean("jump-validation.enabled"); - MAX_JUMP_DISTANCE = Config.CONFIG.isPath("jump-validation.max-distance") ? Config.CONFIG.getDouble("jump-validation.max-distance") : 5.0; - MAX_HORIZONTAL_DISTANCE = Config.CONFIG.isPath("jump-validation.max-horizontal") ? Config.CONFIG.getDouble("jump-validation.max-horizontal") : 4.0; - MAX_VERTICAL_UP = Config.CONFIG.isPath("jump-validation.max-vertical-up") ? Config.CONFIG.getDouble("jump-validation.max-vertical-up") : 1.0; - MAX_VERTICAL_DOWN = Config.CONFIG.isPath("jump-validation.max-vertical-down") ? Config.CONFIG.getDouble("jump-validation.max-vertical-down") : 3.0; - - // Jump types - JUMP_TYPES_ENABLED = Config.CONFIG.isPath("jump-types.enabled") && Config.CONFIG.getBoolean("jump-types.enabled"); - JUMP_TYPE_ENABLED = new java.util.HashMap<>(); - JUMP_TYPE_CHANCE = new java.util.HashMap<>(); - if (Config.CONFIG.isPath("jump-types.types")) { - for (String type : Config.CONFIG.getChildren("jump-types.types")) { - String path = "jump-types.types." + type; - JUMP_TYPE_ENABLED.put(type, Config.CONFIG.isPath(path + ".enabled") && Config.CONFIG.getBoolean(path + ".enabled")); - JUMP_TYPE_CHANCE.put(type, Config.CONFIG.isPath(path + ".chance") ? Config.CONFIG.getDouble(path + ".chance") : 0.0); - } - } - - // Memory - BLOCK_CLEANUP_DISTANCE = Config.CONFIG.isPath("memory.block-cleanup-distance") ? Config.CONFIG.getInt("memory.block-cleanup-distance") : 100; - CLEANUP_INTERVAL = Config.CONFIG.isPath("memory.cleanup-interval") ? Config.CONFIG.getInt("memory.cleanup-interval") : 100; - - // Ghost - GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") && Config.CONFIG.getBoolean("ghost-mode.enabled"); - GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") ? Config.CONFIG.getInt("ghost-mode.show-top") : 3; - GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") ? Config.CONFIG.getDouble("ghost-mode.transparency") : 0.5; - - // Generation chances - TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / 100.0; - TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / 100.0; - TYPE_SCHEMATICS = Config.GENERATION.getInt("generation.type.schematic") / 100.0; - - SPECIAL_ICE = Config.GENERATION.getInt("generation.special.ice") / 100.0; - SPECIAL_SLAB = Config.GENERATION.getInt("generation.special.slab") / 100.0; - SPECIAL_PANE = Config.GENERATION.getInt("generation.special.pane") / 100.0; - SPECIAL_FENCE = Config.GENERATION.getInt("generation.special.fence") / 100.0; - - NORMAL_DISTANCE_1 = Config.GENERATION.getInt("generation.normal.distance.1") / 100.0; - NORMAL_DISTANCE_2 = Config.GENERATION.getInt("generation.normal.distance.2") / 100.0; - NORMAL_DISTANCE_3 = Config.GENERATION.getInt("generation.normal.distance.3") / 100.0; - NORMAL_DISTANCE_4 = Config.GENERATION.getInt("generation.normal.distance.4") / 100.0; - - NORMAL_HEIGHT_1 = Config.GENERATION.getInt("generation.normal.height.1") / 100.0; - NORMAL_HEIGHT_0 = Config.GENERATION.getInt("generation.normal.height.0") / 100.0; - NORMAL_HEIGHT_NEG1 = Config.GENERATION.getInt("generation.normal.height.-1") / 100.0; - NORMAL_HEIGHT_NEG2 = Config.GENERATION.getInt("generation.normal.height.-2") / 100.0; - - MAX_Y = Config.GENERATION.getInt("generation.settings.max-y"); - MIN_Y = Config.GENERATION.getInt("generation.settings.min-y"); - - if (MIN_Y >= MAX_Y) { - MIN_Y = 100; MAX_Y = 200; - LoParkour.getPlugin().getLogger().severe("min-y >= max-y in generation.yml — using defaults 100/200."); - } - } - - private GenerationOptions() {} -} diff --git a/src/main/java/dev/loki/loparkour/config/Locales.java b/src/main/java/dev/loki/loparkour/config/Locales.java index f1691fc..20b1482 100644 --- a/src/main/java/dev/loki/loparkour/config/Locales.java +++ b/src/main/java/dev/loki/loparkour/config/Locales.java @@ -1,13 +1,10 @@ package dev.loki.loparkour.config; -import java.util.ArrayList; - -import dev.loki.loparkour.util.Item; - import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.menu.ParkourOption; import dev.loki.loparkour.player.ParkourUser; - +import dev.loki.loparkour.util.Item; +import dev.loki.loparkour.util.Materials; import dev.lolib.scheduler.Scheduler; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -30,244 +27,194 @@ import java.util.regex.Pattern; import java.util.stream.Stream; /** - * Locale message/item handler. + * Locale system: loading, caching, validation, and access. */ public class Locales { - // a list of all nodes, used to check against missing nodes - private static List resourceNodes; + private static final Map locales = new HashMap<>(); + private static final Pattern REPLACEMENT_PATTERN = Pattern.compile("%[a-z]"); - /** - * A map of all locales with their respective yml trees - */ - public static final Map locales = new HashMap<>(); + // ── public API ──────────────────────────────────────────────────────────── - /** - * Initializes this Locale handler. - */ public static void init() { + loadLocalesAsync(); + } + + @NotNull + public static String getString(@NotNull Player player, @NotNull String path) { + return getString(getPlayerLocale(player), path); + } + + @NotNull + public static String getString(@NotNull String locale, @NotNull String path) { + return cachedValue(locale, c -> c.getString(path), ""); + } + + @NotNull + public static List getStringList(@NotNull String locale, @NotNull String path) { + return cachedValue(locale, c -> c.getStringList(path), Collections.emptyList()); + } + + public static int getLocaleCount() { + synchronized (locales) { return locales.size(); } + } + + @NotNull + public static Set getLocaleKeys() { + synchronized (locales) { return new HashSet<>(locales.keySet()); } + } + + @NotNull + public static Item getItem(@NotNull Player player, @NotNull String path, String... replace) { + return getItem(getPlayerLocale(player), path, replace); + } + + @NotNull + public static Item getItem(@NotNull String locale, @NotNull String path, String... replace) { + synchronized (locales) { + if (locales.isEmpty()) return new Item(Material.STONE, ""); + FileConfiguration config = locales.get(locale); + if (config == null) return new Item(Material.STONE, ""); + return buildItem(config, path, replace); + } + } + + // ── loading (was LocaleLoader) ──────────────────────────────────────────── + + private static void loadLocalesAsync() { Plugin plugin = LoParkour.getPlugin(); - - // Create a temporary map to avoid clearing locales before new ones are loaded - Map newLocales = new HashMap<>(); - Scheduler.get(plugin).runAsync(() -> { - FileConfiguration embedded = YamlConfiguration.loadConfiguration(new InputStreamReader(plugin.getResource("locales/en.yml"), StandardCharsets.UTF_8)); + try { + FileConfiguration embedded = YamlConfiguration.loadConfiguration( + new InputStreamReader(plugin.getResource("locales/en.yml"), StandardCharsets.UTF_8)); - // get all nodes from the plugin's english resource, aka the most updated version - resourceNodes = getChildren(embedded, "", true); + Map loaded = loadFromDisk(embedded); - File folder = LoParkour.getInFolder("locales"); - - // download files to locales folder - if (!folder.exists()) { - folder.mkdirs(); - } - - String[] files = folder.list(); - - // create non-existent files - if (files != null && files.length == 0) { - plugin.saveResource("locales/en.yml", false); - plugin.saveResource("locales/ru.yml", false); - } - - // get all files in locales folder - try (Stream stream = Files.list(folder.toPath())) { - stream.forEach(path -> { - File file = path.toFile(); - - // get locale from file name - String locale = file.getName().split("\\.")[0]; - - LoParkour.log("Found locale " + locale); - - FileConfiguration config = YamlConfiguration.loadConfiguration(file); - validate(embedded, config, file); - - newLocales.put(locale, config); + Scheduler.get(plugin).run(() -> { + synchronized (locales) { + locales.clear(); + locales.putAll(loaded); + } + LoParkour.log("Locales reloaded successfully (" + loaded.size() + " locales)"); }); } catch (Exception ex) { - LoParkour.getPlugin().getLogger().severe("Error while trying to read locale files - restart/reload your server - " + ex.getMessage()); + plugin.getLogger().severe("Error while loading locale files: " + ex.getMessage()); } - - // Only clear and replace locales after all new ones are loaded - Scheduler.get(plugin).run(() -> { - locales.clear(); - locales.putAll(newLocales); - LoParkour.log("Locales reloaded successfully (" + locales.size() + " locales)"); - }); }); } - // validates whether a lang file contains all required keys. - // if it doesn't, automatically add them - private static void validate(FileConfiguration provided, FileConfiguration user, File localPath) { - List userNodes = getChildren(user, "", true); - - for (String node : resourceNodes) { - if (userNodes.contains(node)) { - continue; - } - - LoParkour.log("Fixing missing config node %s in %s".formatted(node, localPath.getName())); - - user.set(node, provided.get(node)); - } - - LoParkour.log("Validated locale " + localPath.getName()); - - try { - user.save(localPath); - } catch (IOException ex) { - LoParkour.getPlugin().getLogger().severe("Error while trying to save fixed config file %s - delete this file and restart your server - ".formatted(localPath) + ex.getMessage()); - } - } - - /** - * Gets a String from the provided path in the provided player's locale. - * If the player is a {@link ParkourUser}, their locale value will be used. - * If not, the default locale will be used. - * - * @param player The player - * @param path The path - * @return a String - */ - public static String getString(Player player, String path) { - ParkourUser user = ParkourUser.getUser(player); - - String locale = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale; - - return getString(locale, path); - } - - /** - * Gets a coloured String from the provided path in the provided locale file - * - * @param locale The locale - * @param path The path - * @return a String - */ - public static String getString(String locale, String path) { - return getValue(locale, config -> config.getString(path), ""); - } - - /** - * Gets an uncoloured String list from the provided path in the provided locale file - * - * @param locale The locale - * @param path The path - * @return a String list - */ - public static List getStringList(String locale, String path) { - return getValue(locale, config -> config.getStringList(path), Collections.emptyList()); - } - - private static T getValue(String locale, Function f, T def) { - if (locales.isEmpty()) { - return def; - } - - FileConfiguration config = locales.get(locale); - - return config != null ? f.apply(config) : def; - } - - /** - * Returns an item from a json locale file. - * The locale is derived from the player. - * If the player is a {@link ParkourUser}, their locale value will be used. - * If not, the default locale will be used. - * - * @param player The player - * @param path The full path of the item in the locale file - * @return a non-null {@link Item} instance built from the description in the locale file - */ @NotNull - public static Item getItem(@NotNull Player player, String path, String... replace) { - ParkourUser user = ParkourUser.getUser(player); - String locale = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale; + private static Map loadFromDisk(@NotNull FileConfiguration embedded) throws Exception { + Map result = new HashMap<>(); + File folder = LoParkour.getInFolder("locales"); + ensureFolder(folder); - return getItem(locale, path, replace); + try (Stream stream = Files.list(folder.toPath())) { + stream.forEach(path -> { + File file = path.toFile(); + if (!file.getName().endsWith(".yml")) return; + + String locale = file.getName().split("\\.")[0]; + LoParkour.log("Found locale " + locale); + + FileConfiguration config = YamlConfiguration.loadConfiguration(file); + validateAndFix(embedded, config, file); + result.put(locale, config); + }); + } + return result; } - private static final Pattern pattern = Pattern.compile("%[a-z]"); + private static void ensureFolder(@NotNull File folder) { + if (!folder.exists()) folder.mkdirs(); + String[] files = folder.list(); + if (files == null || files.length == 0) { + Plugin plugin = LoParkour.getPlugin(); + plugin.saveResource("locales/en.yml", false); + plugin.saveResource("locales/ru.yml", false); + } + } + + // ── validation (was LocaleValidator) ────────────────────────────────────── + + private static void validateAndFix(@NotNull FileConfiguration reference, @NotNull FileConfiguration locale, @NotNull File file) { + List refNodes = getChildren(reference); + List locNodes = getChildren(locale); + + boolean modified = false; + for (String node : refNodes) { + if (!locNodes.contains(node)) { + LoParkour.log("Fixing missing config node %s in %s".formatted(node, file.getName())); + locale.set(node, reference.get(node)); + modified = true; + } + } + if (modified) { + try { locale.save(file); } + catch (IOException ex) { + LoParkour.getPlugin().getLogger().severe( + "Error saving fixed locale %s — delete and restart — %s".formatted(file.getName(), ex.getMessage())); + } + } + LoParkour.log("Validated locale " + file.getName()); + } - /** - * Returns an item from a provided json locale file with possible replacements. - * - * @param locale The locale - * @param path The path in the json file - * @param replace The Strings that will replace any appearances of a String following the regex "%[a-z]" - * @return a non-null {@link Item} instance built from the description in the locale file - */ @NotNull - public static Item getItem(String locale, String path, String... replace) { - if (locales.isEmpty()) { // during reloading - return new Item(Material.STONE, ""); + private static List getChildren(@NotNull FileConfiguration config) { + ConfigurationSection section = config.getConfigurationSection(""); + return section != null ? new ArrayList<>(section.getKeys(true)) : Collections.emptyList(); + } + + // ── cache access (was LocaleCache) ──────────────────────────────────────── + + @NotNull + private static T cachedValue(@NotNull String locale, @NotNull Function extractor, @NotNull T defaultValue) { + synchronized (locales) { + if (locales.isEmpty()) return defaultValue; + FileConfiguration config = locales.get(locale); + if (config == null) return defaultValue; + try { + T result = extractor.apply(config); + return result != null ? result : defaultValue; + } catch (Exception e) { return defaultValue; } } + } - FileConfiguration base = locales.get(locale); + // ── item builder ────────────────────────────────────────────────────────── - String material = base.getString("%s.material".formatted(path)); - String name = base.getString("%s.name".formatted(path)); - String lore = base.getString("%s.lore".formatted(path)); - int modelId = base.getInt("%s.model_id".formatted(path), -1); + @NotNull + private static Item buildItem(@NotNull FileConfiguration config, @NotNull String path, String... replace) { + String material = config.getString("%s.material".formatted(path), "STONE"); + String name = applyReplacements(config.getString("%s.name".formatted(path), ""), replace); + String lore = applyReplacements(config.getString("%s.lore".formatted(path), ""), replace); - if (material == null) { - material = ""; - } - if (name == null) { - name = ""; - } - if (lore == null) { - lore = ""; - } - - int idx = 0; - Matcher matcher = pattern.matcher(name); - while (matcher.find()) { - if (idx == replace.length) { - break; - } - - name = name.replaceFirst(matcher.group(), replace[idx]); - idx++; - } - - matcher = pattern.matcher(lore); - - while (matcher.find()) { - if (idx == replace.length) { - break; - } - - lore = lore.replaceFirst(matcher.group(), replace[idx]); - idx++; - } - - Material mat = Material.getMaterial(material.toUpperCase()); + Material mat = Materials.parse(material); if (mat == null) { - LoParkour.getPlugin().getLogger().warning("Invalid material '%s' for locale path '%s', using STONE as fallback".formatted(material, path)); + LoParkour.getPlugin().getLogger().warning("Invalid material '%s' for locale path '%s', using STONE".formatted(material, path)); mat = Material.STONE; } Item item = new Item(mat, name); - - if (!lore.isEmpty()) { - item.lore(lore.split("\\|\\|")); - } - - if (modelId != -1) { - // TODO: item.setCustomModelData(modelId); - } - + if (!lore.isEmpty()) item.lore(lore.split("\\|\\|")); return item; } - public static List getChildren(FileConfiguration file, String path, boolean deep) { - ConfigurationSection section = file.getConfigurationSection(path); + @NotNull + private static String applyReplacements(@NotNull String text, String... replacements) { + if (replacements.length == 0) return text; + String result = text; + int idx = 0; + Matcher matcher = REPLACEMENT_PATTERN.matcher(result); + while (matcher.find() && idx < replacements.length) { + result = result.replaceFirst(matcher.group(), replacements[idx++]); + } + return result; + } - return section != null ? new ArrayList<>(section.getKeys(deep)) : Collections.emptyList(); + @NotNull + private static String getPlayerLocale(@NotNull Player player) { + ParkourUser user = ParkourUser.getUser(player); + String loc = user == null ? Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG) : user.locale; + return (loc != null && !loc.isBlank()) ? loc : "en"; } } diff --git a/src/main/java/dev/loki/loparkour/config/Option.java b/src/main/java/dev/loki/loparkour/config/Option.java index c26321a..34344a0 100644 --- a/src/main/java/dev/loki/loparkour/config/Option.java +++ b/src/main/java/dev/loki/loparkour/config/Option.java @@ -1,6 +1,7 @@ package dev.loki.loparkour.config; import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.util.Materials; import dev.loki.loparkour.api.Registry; import dev.loki.loparkour.menu.ParkourOption; import dev.loki.loparkour.style.RandomStyle; @@ -9,24 +10,12 @@ import dev.loki.loparkour.util.ParticleData; import org.bukkit.*; import org.bukkit.block.BlockFace; import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.util.Vector; import java.util.*; import java.util.function.BiFunction; /** - * Top-level config constants used throughout the plugin. - * - * Heavy sub-sections are delegated to focused classes: - *

      - *
    • {@link SqlOptions} – SQL connection settings
    • - *
    • {@link ParticleOptions} – particle & sound settings
    • - *
    • {@link GenerationOptions} – generation & jump-validation settings
    • - *
    - * - * Those classes expose their own static fields. Option re-exports the most - * commonly accessed ones via forwarding fields so existing call-sites compile - * without changes. + * All config-derived constants used at runtime. */ public class Option { @@ -41,9 +30,8 @@ public class Option { public static Location GO_BACK_LOC; - // ── Forwarded from sub-classes (backwards compat) ───────────────────────── + // ── SQL ─────────────────────────────────────────────────────────────────── - /** @see SqlOptions#SQL */ public static boolean SQL; public static String SQL_URL; public static int SQL_PORT; @@ -52,38 +40,49 @@ public class Option { public static String SQL_PASSWORD; public static String SQL_PREFIX; - /** @see ParticleOptions#SHAPE */ - public static ParticleShape PARTICLE_SHAPE; - /** @see ParticleOptions#SOUND_TYPE */ - public static Sound SOUND_TYPE; - /** @see ParticleOptions#SOUND_PITCH */ - public static int SOUND_PITCH; - /** @see ParticleOptions#SOUND_VOLUME */ - public static int SOUND_VOLUME; - /** @see ParticleOptions#PARTICLE_TYPE */ - public static Particle PARTICLE_TYPE; - /** @see ParticleOptions#PARTICLE_DATA */ + // ── Particles / Sound ───────────────────────────────────────────────────── + + public static ParticleShape PARTICLE_SHAPE; + public static Sound SOUND_TYPE; + public static int SOUND_PITCH; + public static int SOUND_VOLUME; + public static Particle PARTICLE_TYPE; public static ParticleData PARTICLE_DATA; - /** @see GenerationOptions */ + // ── Generation ──────────────────────────────────────────────────────────── + public static double TYPE_NORMAL, TYPE_SPECIAL, TYPE_SCHEMATICS; public static double SPECIAL_ICE, SPECIAL_SLAB, SPECIAL_PANE, SPECIAL_FENCE; public static double NORMAL_DISTANCE_1, NORMAL_DISTANCE_2, NORMAL_DISTANCE_3, NORMAL_DISTANCE_4; public static double NORMAL_HEIGHT_1, NORMAL_HEIGHT_0, NORMAL_HEIGHT_NEG1, NORMAL_HEIGHT_NEG2; public static int MAX_Y, MIN_Y; public static int BLOCK_CLEANUP_DISTANCE, CLEANUP_INTERVAL; + /** Block indices to keep behind the rearmost player before trail air-cleanup. */ + public static int TRAIL_KEEP_BEHIND; public static boolean GHOST_MODE_ENABLED; public static int GHOST_SHOW_TOP; public static double GHOST_TRANSPARENCY; + // ── Jump validation ─────────────────────────────────────────────────────── + + public static boolean JUMP_VALIDATION_ENABLED; + public static double MAX_JUMP_DISTANCE; + public static double MAX_HORIZONTAL_DISTANCE; + public static double MAX_VERTICAL_UP; + public static double MAX_VERTICAL_DOWN; + + // ── Jump types ──────────────────────────────────────────────────────────── + + public static boolean JUMP_TYPES_ENABLED; + public static Map JUMP_TYPE_ENABLED; + public static Map JUMP_TYPE_CHANCE; + // ── init ────────────────────────────────────────────────────────────────── public static void init(boolean firstLoad) { - SqlOptions.init(); - ParticleOptions.init(); - GenerationOptions.init(); - - syncForwardedFields(); + initSql(); + initParticles(); + initGeneration(); initGeneral(firstLoad); initOptions(); @@ -91,60 +90,139 @@ public class Option { .forEach(Registry::register); } - /** Copy sub-class fields to this class so existing code still compiles. */ - private static void syncForwardedFields() { - SQL = SqlOptions.SQL; - SQL_URL = SqlOptions.URL; - SQL_PORT = SqlOptions.PORT; - SQL_DB = SqlOptions.DB; - SQL_USERNAME = SqlOptions.USERNAME; - SQL_PASSWORD = SqlOptions.PASSWORD; - SQL_PREFIX = SqlOptions.PREFIX; + // ── SQL ─────────────────────────────────────────────────────────────────── - PARTICLE_SHAPE = ParticleOptions.SHAPE; - SOUND_TYPE = ParticleOptions.SOUND_TYPE; - SOUND_PITCH = ParticleOptions.SOUND_PITCH; - SOUND_VOLUME = ParticleOptions.SOUND_VOLUME; - PARTICLE_TYPE = ParticleOptions.PARTICLE_TYPE; - PARTICLE_DATA = ParticleOptions.PARTICLE_DATA; - - TYPE_NORMAL = GenerationOptions.TYPE_NORMAL; - TYPE_SPECIAL = GenerationOptions.TYPE_SPECIAL; - TYPE_SCHEMATICS = GenerationOptions.TYPE_SCHEMATICS; - - SPECIAL_ICE = GenerationOptions.SPECIAL_ICE; - SPECIAL_SLAB = GenerationOptions.SPECIAL_SLAB; - SPECIAL_PANE = GenerationOptions.SPECIAL_PANE; - SPECIAL_FENCE = GenerationOptions.SPECIAL_FENCE; - - NORMAL_DISTANCE_1 = GenerationOptions.NORMAL_DISTANCE_1; - NORMAL_DISTANCE_2 = GenerationOptions.NORMAL_DISTANCE_2; - NORMAL_DISTANCE_3 = GenerationOptions.NORMAL_DISTANCE_3; - NORMAL_DISTANCE_4 = GenerationOptions.NORMAL_DISTANCE_4; - - NORMAL_HEIGHT_1 = GenerationOptions.NORMAL_HEIGHT_1; - NORMAL_HEIGHT_0 = GenerationOptions.NORMAL_HEIGHT_0; - NORMAL_HEIGHT_NEG1 = GenerationOptions.NORMAL_HEIGHT_NEG1; - NORMAL_HEIGHT_NEG2 = GenerationOptions.NORMAL_HEIGHT_NEG2; - - MAX_Y = GenerationOptions.MAX_Y; - MIN_Y = GenerationOptions.MIN_Y; - - BLOCK_CLEANUP_DISTANCE = GenerationOptions.BLOCK_CLEANUP_DISTANCE; - CLEANUP_INTERVAL = GenerationOptions.CLEANUP_INTERVAL; - - GHOST_MODE_ENABLED = GenerationOptions.GHOST_MODE_ENABLED; - GHOST_SHOW_TOP = GenerationOptions.GHOST_SHOW_TOP; - GHOST_TRANSPARENCY = GenerationOptions.GHOST_TRANSPARENCY; + private static void initSql() { + SQL = Config.CONFIG.getBoolean("sql.enabled"); + SQL_PORT = Config.CONFIG.getInt("sql.port"); + SQL_DB = Config.CONFIG.getString("sql.database"); + SQL_URL = Config.CONFIG.getString("sql.url"); + SQL_USERNAME = Config.CONFIG.getString("sql.username"); + String envPassword = System.getenv("LOPARKOUR_SQL_PASSWORD"); + SQL_PASSWORD = (envPassword != null && !envPassword.isEmpty()) + ? envPassword + : Config.CONFIG.getString("sql.password"); + SQL_PREFIX = Config.CONFIG.getString("sql.prefix"); } - // ── General init ────────────────────────────────────────────────────────── + // ── Particles / Sound ───────────────────────────────────────────────────── + + private static void initParticles() { + SOUND_TYPE = resolveSound(Config.CONFIG.getString("particles.sound-type")); + PARTICLE_TYPE = resolveParticle(Config.CONFIG.getString("particles.particle-type")); + PARTICLE_SHAPE = resolveShape(Config.CONFIG.getString("particles.particle-shape")); + + SOUND_PITCH = Config.CONFIG.getInt("particles.sound-pitch"); + SOUND_VOLUME = Config.CONFIG.getInt("particles.sound-volume"); + PARTICLE_DATA = new ParticleData<>(PARTICLE_TYPE, null, 10); + } + + private static Sound resolveSound(String name) { + if (name != null && !name.isEmpty()) { + try { return Sound.valueOf(name.toUpperCase()); } + catch (IllegalArgumentException ignored) {} + } + for (String fb : new String[]{"BLOCK_NOTE_BLOCK_GUITAR", "BLOCK_NOTE_BLOCK_PLING", "BLOCK_NOTE_PLING"}) { + try { return Sound.valueOf(fb); } + catch (IllegalArgumentException ignored) {} + } + LoParkour.getPlugin().getLogger().warning("Could not resolve any sound, using first available."); + return Sound.values()[0]; + } + + private static Particle resolveParticle(String name) { + if (name != null && !name.isEmpty()) { + try { return Particle.valueOf(name.toUpperCase()); } + catch (IllegalArgumentException ignored) {} + } + for (String fb : new String[]{"INSTANT_EFFECT", "SPELL_INSTANT", "CRIT"}) { + try { return Particle.valueOf(fb); } + catch (IllegalArgumentException ignored) {} + } + LoParkour.getPlugin().getLogger().warning("Could not resolve any particle, using first available."); + return Particle.values()[0]; + } + + private static ParticleShape resolveShape(String name) { + if (name != null && !name.isEmpty()) { + try { return ParticleShape.valueOf(name.toUpperCase()); } + catch (IllegalArgumentException ignored) {} + } + return ParticleShape.BOX; + } + + // ── Generation ──────────────────────────────────────────────────────────── + + private static void initGeneration() { + JUMP_VALIDATION_ENABLED = Config.CONFIG.isPath("jump-validation.enabled") && Config.CONFIG.getBoolean("jump-validation.enabled"); + MAX_JUMP_DISTANCE = Config.CONFIG.isPath("jump-validation.max-distance") ? Config.CONFIG.getDouble("jump-validation.max-distance") : 5.0; + MAX_HORIZONTAL_DISTANCE = Config.CONFIG.isPath("jump-validation.max-horizontal") ? Config.CONFIG.getDouble("jump-validation.max-horizontal") : 4.0; + MAX_VERTICAL_UP = Config.CONFIG.isPath("jump-validation.max-vertical-up") ? Config.CONFIG.getDouble("jump-validation.max-vertical-up") : 1.0; + MAX_VERTICAL_DOWN = Config.CONFIG.isPath("jump-validation.max-vertical-down") ? Config.CONFIG.getDouble("jump-validation.max-vertical-down") : 3.0; + + JUMP_TYPES_ENABLED = Config.CONFIG.isPath("jump-types.enabled") && Config.CONFIG.getBoolean("jump-types.enabled"); + JUMP_TYPE_ENABLED = new HashMap<>(); + JUMP_TYPE_CHANCE = new HashMap<>(); + if (Config.CONFIG.isPath("jump-types.types")) { + for (String type : Config.CONFIG.getChildren("jump-types.types")) { + String p = "jump-types.types." + type; + JUMP_TYPE_ENABLED.put(type, Config.CONFIG.isPath(p + ".enabled") && Config.CONFIG.getBoolean(p + ".enabled")); + JUMP_TYPE_CHANCE.put(type, Config.CONFIG.isPath(p + ".chance") ? Config.CONFIG.getDouble(p + ".chance") : 0.0); + } + } + + BLOCK_CLEANUP_DISTANCE = Config.CONFIG.isPath("memory.block-cleanup-distance") ? Config.CONFIG.getInt("memory.block-cleanup-distance") : 100; + CLEANUP_INTERVAL = Config.CONFIG.isPath("memory.cleanup-interval") ? Config.CONFIG.getInt("memory.cleanup-interval") : 100; + TRAIL_KEEP_BEHIND = Config.CONFIG.isPath("memory.trail-keep-behind") ? Config.CONFIG.getInt("memory.trail-keep-behind") : 10; + + GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") && Config.CONFIG.getBoolean("ghost-mode.enabled"); + GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") ? Config.CONFIG.getInt("ghost-mode.show-top") : 3; + GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") ? Config.CONFIG.getDouble("ghost-mode.transparency") : 0.5; + + TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / 100.0; + TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / 100.0; + TYPE_SCHEMATICS = Config.GENERATION.getInt("generation.type.schematic") / 100.0; + + SPECIAL_ICE = Config.GENERATION.getInt("generation.special.ice") / 100.0; + SPECIAL_SLAB = Config.GENERATION.getInt("generation.special.slab") / 100.0; + SPECIAL_PANE = Config.GENERATION.getInt("generation.special.pane") / 100.0; + SPECIAL_FENCE = Config.GENERATION.getInt("generation.special.fence") / 100.0; + + NORMAL_DISTANCE_1 = Config.GENERATION.getInt("generation.normal.distance.1") / 100.0; + NORMAL_DISTANCE_2 = Config.GENERATION.getInt("generation.normal.distance.2") / 100.0; + NORMAL_DISTANCE_3 = Config.GENERATION.getInt("generation.normal.distance.3") / 100.0; + NORMAL_DISTANCE_4 = Config.GENERATION.getInt("generation.normal.distance.4") / 100.0; + + NORMAL_HEIGHT_1 = Config.GENERATION.getInt("generation.normal.height.1") / 100.0; + NORMAL_HEIGHT_0 = Config.GENERATION.getInt("generation.normal.height.0") / 100.0; + NORMAL_HEIGHT_NEG1 = Config.GENERATION.getInt("generation.normal.height.-1") / 100.0; + NORMAL_HEIGHT_NEG2 = Config.GENERATION.getInt("generation.normal.height.-2") / 100.0; + + MAX_Y = Config.GENERATION.getInt("generation.settings.max-y"); + MIN_Y = Config.GENERATION.getInt("generation.settings.min-y"); + + if (MIN_Y >= MAX_Y) { + MIN_Y = 100; MAX_Y = 200; + LoParkour.getPlugin().getLogger().severe("min-y >= max-y in generation.yml — using defaults 100/200."); + } + } + + // ── General ─────────────────────────────────────────────────────────────── private static void initGeneral(boolean firstLoad) { GO_BACK_LOC = parseLocation(Config.CONFIG.getString("bungeecord.go-back")); - String[] axes = Config.CONFIG.getString("bungeecord.go-back-axes").split(","); - GO_BACK_LOC.setPitch(Float.parseFloat(axes[0])); - GO_BACK_LOC.setYaw(Float.parseFloat(axes[1])); + try { + String axesRaw = Config.CONFIG.getString("bungeecord.go-back-axes"); + if (!axesRaw.isEmpty()) { + String[] axes = axesRaw.split(","); + if (axes.length >= 2) { + GO_BACK_LOC.setPitch(Float.parseFloat(axes[0].trim())); + GO_BACK_LOC.setYaw(Float.parseFloat(axes[1].trim())); + } + } + } catch (Exception ex) { + LoParkour.getPlugin().getLogger().warning("Invalid bungeecord.go-back-axes, using 0,0"); + } POSSIBLE_LEADS = Config.CONFIG.getIntList("options.leads.amount"); for (int lead : new ArrayList<>(POSSIBLE_LEADS)) { @@ -193,20 +271,31 @@ public class Option { // ── Helpers ─────────────────────────────────────────────────────────────── private static Location parseLocation(String location) { - String[] values = location.replaceAll("[()]", "").replaceAll("[, ]", " ").split(" "); - World world = Bukkit.getWorld(values[3]); - if (world == null) world = Bukkit.getWorlds().get(0); - return new Location(world, Double.parseDouble(values[0]), - Double.parseDouble(values[1]), Double.parseDouble(values[2])); + try { + String[] values = location.replaceAll("[()]", "").replaceAll("[, ]", " ").trim().split("\\s+"); + if (values.length < 4) { + throw new IllegalArgumentException("Expected 4 values (x,y,z,world), got " + values.length); + } + World world = Bukkit.getWorld(values[3]); + if (world == null) world = Bukkit.getWorlds().get(0); + return new Location(world, Double.parseDouble(values[0]), + Double.parseDouble(values[1]), Double.parseDouble(values[2])); + } catch (Exception ex) { + LoParkour.getPlugin().getLogger().warning( + "Invalid bungeecord.go-back value '" + location + "'. Falling back to spawn world origin."); + World fallback = Bukkit.getWorlds().isEmpty() ? null : Bukkit.getWorlds().get(0); + return new Location(fallback, 0, 100, 0); + } } public static Set