refactor: decompose god-classes, add lint gates, restructure packages

- Decompose all 11 god-classes (>200 lines) into facade + collaborators
  (command/, schematic/, config/, generator/, mode/, papi/, adaptive/,
  storage/, player/, leaderboard/) — every file is now <=200 lines
- Add PMD 7 (report-only) + stricter checkstyle rules (NPath, IllegalCatch,
  NestedDepth, Whitespace, MissingOverride); fix real bugs found
  (printStackTrace, resource leaks in ConfigLoader/SQLStatsStorage)
- docs/ARCHITECTURE.md: target structure + 'one folder = one purpose' rule
- Pilot restructure: command/core|util/, mode/impl/solo|multi|barrier/,
  hook/papi/resolver/; cross-package classes made public
- TODO.md: track warning cleanup (132-138) + restructure progress
This commit is contained in:
loki5512344 2026-08-05 18:33:21 +02:00
parent 1d57972038
commit 81788e71ed
Signed by: boba
GPG key ID: 253067914055423B
70 changed files with 3428 additions and 1701 deletions

3
.gitignore vendored
View file

@ -72,3 +72,6 @@ TODO.md
# Compiled output
bin/
# Reference material (local only)
ref/

260
TODO.md
View file

@ -1,6 +1,6 @@
# LoParkour Bug Tracker
# LoParkour Bug Tracker & Task List
## ✅ Fixed (19/20)
## ✅ Исправлено (история, 19/20)
### CRITICAL (7/7 fixed)
@ -36,4 +36,258 @@
---
**Всего найдено: 20 | Исправлено: 19 | Пропущено: 1 (main-thread-only)**
## 🆕 Открыто после полного аудита (2026-08-05)
Пометки: `[P1]` критично, `[P2]` высоко, `[P3]` средне, `[P4]` низко.
---
## 🔴 [P1] Критическое — чинить в первую очередь
### Функциональность, которая не работает
- **21. Folia-поддержка — фикция.** Нет `folia-plugin.yml` (Folia не загрузит плагин). `MetricsCollector` использует удалённое API `BukkitRunnable.runTaskTimerAsynchronously` (MetricsCollector.java:73). `SessionStateManager.runTimer` → GlobalRegionScheduler, но тик мутирует блоки/entities (SessionStateManager.java:76, LifecycleTickManager.java:75, BlockPlacer.java:158, teleport в ParkourUser.java:93-95). → Либо выпилить бейдж Folia из README, либо: добавить folia-plugin.yml, перевести тик на runTimerAtLocation/runAtEntity, заменить BukkitRunnable.
- **22. Ghost-система — 100% мёртвый код.** `GhostManager`/`GhostRecorder`/`GhostPlayer` нигде не инстанцируются (grep: только само-ссылки). Конфиг `ghost-mode.*` не потребляется. → Подключить к lifecycle (запись в saveGhost/спавн в spawnGhosts) ИЛИ удалить подсистему и конфиг.
- **23. Coop-режим нерабочий.** Ни одно `Session.create` не строит `CoopGenerator` (CoopMode.java:134). `/parkour join <игрок>` ведёт в спектаторы. → Реализовать создание coop-сессии (лобби/инвайты) или выпилить режим из меню и регистрации.
- **24. Coop: латентный StackOverflow.** `ParkourPlayer.unregister()` → `CoopMode.leave()` → `ParkourUser.leave()` → `UserRegistry.unregister()` → `user.unregister()` — бесконечная рекурсия (ParkourPlayer.java:102-107, CoopMode.java:115-121, UserRegistry.java:58-67). Нет guard-флага. → Добавить in-flight guard в unregister (например, reentrancy flag в ParkourUser).
- **25. Лидерборд: тайминги сломаны.** `Score.getTimeMillis()` ждёт ровно 3 части через `:` (Score.java:36-55), но генератор пишет `mm:ss.SSS` (2 части) и `HH:mm:ss.SSS` (третья `02.123` → NFE). Итог: TIME-сортировка и тай-брейк мёртвые, Race-лидерборд не обновляется. Плюс переполнение `int` в m*60*1000+... (Score.java:51). → Унифицировать формат времени; парсить `mm:ss.SSS` и `HH:mm:ss.SSS`; перейти на `long`.
- **26. ConfigUpdater уничтожает пользовательские данные.** `merge()` перегенерирует весь файл из шаблона при каждом старте (ConfigUpdater.java:85-127): теряются листы (`options.leads.amount`, стили), кастомные ключи, комментарии; алгоритм отступов (`prevIndent -= 2`) ошибочен; `ignoredSections` не защищают. → Точечный merge только отсутствующих ключей, сохранять листы/комментарии, либо заменить на библиотеку (e.g. `me.mattstudios` / SimpleConfig) или `Bukkit's config addDefaults`.
- **27. SQL: утечка Hikari-пулов.** `StorageSQL.init(mode)` вызывается на каждый режим (6 лидербордов), каждый раз пересоздаёт `connectionManager` (StorageSQL.java:66-77, LeaderboardStorage.java:35) → до ~10 соединений висят навсегда. → Один пул на плагин, инициализация один раз.
- **28. SQL: блокирующий connect на main thread** (SQLConnectionManager.java:34-56) — при лежащей БД включение висит до 5с×режимы. → `setInitializationFailTimeout(-1)` + асинхронный connect.
- **29. SQL: адаптив не стартует.** `AdaptiveServices.init` вызывается до создания лидербордов, а `StorageSQL.runWhenConnected` выбрасывает колбэк при не-подключении (StorageSQL.java:45-51). → Очередь колбэков в SQLConnectionManager.onConnectCallbacks; порядок init: лидерборды → storage → adaptive.
- **30. SQL: настройки игрока не сохраняются.** `SQLDataMapper.serializeSettings` всегда `"{}"`, десериализация no-op (SQLDataMapper.java:105-115) → при входе `schematicDifficulty`/`blockLead` и др. = null → NPE в `ParkourPlayer.updateGeneratorSettings:157-164` и `PAPIHook.java:140`. → Реализовать сериализацию/десериализацию всех настроек (JSON-колонка) + fallback на defaults.
- **31. Схематики: креш на main thread от битых файлов.** `LPSchematicLegacy`/`SchemLoader` парсят без валидации; NPE/AIOOBE мимо `catch(IOException)` (SchematicManager.java:109-133, LpschemConverter.java:44-54). Битый файл = отказ включения плагина. → Валидация dimensions/palette/frameCount + `catch(RuntimeException)` + лимиты размера файла/блоков.
- **32. JumpCalculator: возврат невалидного прыжка.** После 10 неудач валидация сдаётся и возвращает заведомо невалидный блок (JumpCalculator.java:125-136). → Пере-роллить offset/направление, а не только уменьшать distance/height; при исчерпании — safety-fallback (простой допустимый шаг).
- **33. JumpCalculator: диагональный heading залипает.** `JumpDirector.getRecommendedHeading` в безопасной зоне возвращает current без изменений (JumpDirector.java:105-107), а `calculateJumpOffset` при `offset.getX()!=0` перезаписывает Z (JumpCalculator.java:110-115) → курс зигзагом и не выпрямляется. → При диагонали аппендикс не перезаписывать случайным; корректный поворот-домножитель.
### Генерация / lifecycle
- **34. Удаление неверных блоков схем.** `handleSchematicEndBlock` → `removeTrailBlocks(0)` удаляет старейшие 20 блоков курса, а не схематику (PlayerInteractionHandler.java:133-142); блоки схематики остаются в мире как скоробл. → Хранить координаты схематики отдельно и чистить их; не смешивать с history.
- **35. Ложные падения.** `fallThreshold = spawnY - 30` при спуске ~2 блока/прыжок → длинный спуск (>15 прыжков) ресетит живого игрока (LifecycleTickManager.java:72). → Считать порог относительно последнего блока / прогресса, а не спавна.
- **36. Генерация вхолостую.** `maintainBlockLead` сравнивает `score+2` с `history.size()`, но `cleanupDistantBlocks` срезает голову истории каждые 100 тиков → 5 блоков/тик генерится даже когда игрок стоит (LifecycleTickManager.java:89-98). → Считать lead по координате последнего сгенерированного блока, а не size истории.
- **37. One-time награды повторяются.** `collectedRewards` нигде не читается (LifecycleEventManager.java:82-86) → награда за N очков выдаётся в каждом забеге. → Проверять и помечать collectedRewards при выдаче.
- **38. Мёртвый конфиг генератора.** `jump-validation.*` (JumpValidator жёстко зашит: 4.5/4.1/1.25/3.0), `jump-types.*`, `ghost-mode.*`, `useSpecialBlocks`, `blockLead`, `EffectManager` — не читаются нигде. → Подключить или удалить из config.yml/generation.yml.
## 🟠 [P2] Высокое
### Потокобезопасность
- **39. `Divider.SECTIONS` — обычный HashMap, синхронизирован только add** (Divider.java:26,33-54); итерации на quit-патче и в листенерах без блокировки → CME. → ConcurrentHashMap или полная синхронизация read-путей.
- **40. `PlayerMetrics` не потокобезопасен** при том, что тик-поток пишет, а async-автосейв (5 мин) читает (MetricsCollector.java:148-190; jumpTypeStats — HashMap). → copy-on-read или атомарные поля/ConcurrentHashMap.
- **41. `FileStatsStorage` — гонка read-modify-write** между тиком (fall) и async-сейвом → потерянные апдейты/битые JSON (FileStatsStorage.java:53-67,94-114). → Сериализация записи (ReentrantLock / очередь сейвов).
- **42. `Leaderboard.put` сортирует вне лока**, а async-таймер мутирует карту (Leaderboard.java:83-96, LeaderboardStorage.java:63-73) → CME и окно пустого/неверного лидерборда. → Сортировка под `synchronized(scores)`; не хранить пре-сортированную карту.
- **43. `StorageDisk` — неатомарная запись файла** (таймер + синхронный shutdown пишут одновременно). → tmp-файл + rename, файловый лок.
- **44. `onChat` (async) итерирует сессионные ArrayList/`Divider.SECTIONS`** (ParkourRestrictionListener.java:36-60) → CME. → Снимок списков или синхронизация.
### Player
- **45. `PreviousData` не восстанавливает: скорость ходьбы/полёта, атрибуты, опыт, невидимость, питомцев, коллайдер, чужой скорборд** (PreviousData.java:26-95; ParkourPlayer.java:62-64 принудительно снимает flight/invisible). → Расширить capture/restore; восстановление исходного scoreboard.
- **46. `Storage.readPlayer` синхронно на main thread при join** (UserRegistry.java:46) — файл/DB блокируют тик. → Async-чтение с кэшем или схемой «join сразу, подгрузка настроек позже».
- **47. `UserRegistry.getUsers()` строит полный Set на каждый BlockBreak/Place/Drop/Damage** (UserRegistry.java:94-98) → O(n) аллокаций на горячем пути. → Кэш `Map<UUID, ParkourUser>` + привязка через PersistentDataContainer или entity.
- **48. `sendTranslated(...).formatted()` бросает IllegalFormatException при `%` в нике/сообщении** (ParkourUser.java:102, Locales.java:76-82). → Обернуть в try или использовать replace-based формат.
- **49. `getJoinCount` — int между main и async (bStats)** (UserRegistry.java:100-104). → AtomicInteger.
### Modes
- **50. `GravityShiftMode` не снимает эффекты при выходе игрока** (только в reset для присутствующих, GravityShiftMode.java:149-163). → Снимать в unregister.
- **51. `RaceMode.finishRace` — `reset(false)` оставляет игрока в воздухе** (RaceMode.java:142-147; GeneratorCleanup.reset чистит курс без регенерации/телепорта). → Телепорт в лобби + регенерация старта.
- **52. `SpeedrunMode` оставляет удалённые блоки в `history`** → ломает координатные поиски. → Удалять координату из history при снятии блока.
### Схематики (жизнь в игре)
- **53. Паста схематики синхронно на main thread** (BlockPlacer.java:192-226) — большая схема = фриз. → Async-паста с FastAsyncWorldEdit или chunk-снапшоты.
- **54. `StructurePaster` записывает в «схему» все не-air блоки бокса**, включая чужие (платформа, курс) (StructurePaster.java:44-54) → потом удаляются. → Записывать только блоки, поставленные самой пастой (сравнивать с snapshot до).
- **55. `SchemPaster` использует `getMinimumPoint()` вместо `getOrigin()`** (SchemPaster.java:27,36) → смещённая вставка. → `clipboard.getOrigin()`.
- **56. Загрузка всех схем синхронно на enable/reload** (SchematicManager.java:87-133). → Async-загрузка с прогрессом.
### Призы / награды
- **57. `%player%` через `String.replaceAll`** — `$`/`\` в нике кидают IllegalArgumentException (Reward.java:43-45). → `replace(..., literal)`.
- **58. `contains("vault:")` case-insensitive, но `replaceFirst("vault:")` — нет** → `VAULT:100` падает с NFE (Reward.java:55-61). → Единый lowercase-обработчик.
- **59. Нет валидации сумм Vault** — `vault:-100` уводит баланс в минус (Rewards.parseScores). → Клэмпить >=0 или блокировать отрицательные.
## 🟡 [P3] Среднее
### SQL / storage
- **60. `SQLDataMapper` UUID.fromString кидает unchecked IllegalArgumentException** (SQLDataMapper.java:65,31) мимо catch(SQLException) → битые строки роняют чтение. → Ловить и логировать/скипать строку.
- **61. `SQLQueryExecutor.prepareStatement`** (без callers) никогда не закрывает connection → утечка пула при будущем использовании. → try-with-resources / borrow-tracking.
- **62. `SQLMigrationManager` мёртв; `migration_adaptive.sql` ссылается на несуществующую `loparkour_scores` и нигде не исполняется**; та же DDL в 3 копиях; префикс `SQL_PREFIX` применён непоследовательно. → Единый мигратор с версионированием; починить/удалить SQL-файл.
- **63. `writeScores` гоняет всю таблицу туда-сюда** без дельт (StorageSQL.java:105-128). → Инкрементальные upsert'ы только изменённых записей.
- **64. `Score.fromString` — CSV по запятой: ник с `,` ломает все поля** (Score.java:19-31). → Экранирование или JSON.
- **65. `validateConnection` мутирует `Option.SQL=false` при транзиентном сбое** (SQLConnectionManager.java:81-86) — SQL отключается навсегда. → Отдельный флаг/ретрай.
### Adaptive
- **66. `NEAR_MISS_THRESHOLD=3.5` хардкод при живом конфиге `adaptive.near-miss-distance` (0.3)** (MetricsCollector.java:32-33); `getNearMissDistance/getUpdateInterval/getConfidenceThreshold` без callers. → Подключить конфиг.
- **67. Разные формулы confidence при записи и чтении** (SkillRating.java:54-62 vs SQLStatsStorage.java:48). → Единый метод.
- **68. Мёртвый API адаптива**: `estimateMinRating/estimateMaxRating/calculateRecommendedRating/isReadyForIncrease/shouldDecrease/smoothTransition/estimateDifficulty/getEffectiveRating`, `incrementJumps/incrementFalls/updateLongestStreak`, `PendingUpdate.flushBatch`, `NoOpStatsRepository`. → Удалить или до-реализовать.
- **69. `reload()` гоняет `saveAllMetrics()` на command thread** (AdaptiveServices.java:52-63). → Async.
### Config / reload
- **70. `/lp reload` не атомарен**: дублирует `Registry.STYLES` (никогда не чистится), оставляет старые `Rewards` при `enabled:false`, `BORDER_SIZE` устаревший, миры/генераторы не обновляются (ConfigUpdater, OptionStyles.java:23-43, Rewards.java:41-52, OptionGeneral.java:38-40). → Полный reset registry + пересоздание зависимых подсистем.
- **71. `LocaleLoader` при ошибке подменяет кэш на emptyMap** → все сообщения пустые (LocaleCache.java:22-25). → Сохранять старый кэш при сбое.
- **72. Нет fallback на en** для пропущенных ключей/неизвестных локалей (Locales.java:69-71). → Fallback-цепочка locale → en.
- **73. `Rewards.init()` при `enabled:false` не чистит статические мапы** → старые награды продолжают выдаваться после reload. → Очистка.
- **74. `ConfigAccessor` типо-опечатка тихо даёт false/0** (ConfigAccessor.java:45-48). → Строгая валидация ключей при старте.
### Session
- **75. `catch(Throwable)` вокруг `generator.tick()` без стека + сообщение игрокам** (SessionStateManager.java:80-83). → Логировать стек, не спамить чат.
- **76. `SessionUserManager` — целиком мёртвый дубликат SessionPlayerManager**; `updateVisibility` O(n²) showPlayer. → Удалить/объединить, кэшировать видимость.
- **77. `Session.generator` — публичное mutable поле** (Session.java:23). → private + getter.
### Menu / GUI
- **78. `currentPage` — общий поле-синглтон на всех игроков** (SingleMenu.java:20, SpectatorMenu, LeaderboardsMenu, SingleLeaderboardMenu) — двое листают → перезаписывают друг друга. → Per-player состояние (Map<UUID, Integer> или GUI-инстанс на игрока).
- **79. Скилл-головы на лидерборде не рендерятся**: `setOwningPlayer`, затем `skull.setItemMeta(built)` метой STONE (SingleLeaderboardMenu.java:53-80; `head.material` пуст). → Применять name/lore к той же SkullMeta, не перезаписывать.
- **80. `LangMenu` показывает первые 7 неупорядоченных ключей без пагинации** (LangMenu.java:37-54); `ParkourSettingsMenu` обрезает стили на 7. → Сортировка + пагинация.
- **81. Клик-обработчики не проверяют игрока** — общий стейт меню. → Привязка к игроку.
### Command / security
- **82. `getCommand("LoParkour")` без null-guard** (PluginBootstrap.java:118-119) — работает только из-за lowercase-fallback. → Null-проверка.
- **83. Схематик-хендлеры без собственных permission-проверок** (SchematicCommandHandler) — всё держится на вызывающем. → Проверка в каждом handler.
- **84. `SchematicCommandHandler.SELECTIONS` кэш ключён по Player и не чистится на выход** → утечка памяти (SchematicCommandHandler.java:38,58-71). → Удалять при quit/инвалидации.
- **85. Tab-completion предлагает отключённые режимы** (LoParkourCommand.java:232-236); `Registry.getMode` case-sensitive, лидерборд-аргумент — нет. → Унифицировать case/фильтр по enabled.
- **86. `admin`-нода даёт доступ к `recoverinventory` другого игрока и глобальному reset** (ParkourOption.java:49). → Разделить permission-ноды.
### Hooks / util
- **87. `HoloHook` читает `split[1],split[2]` без проверки длины** → AIOOBE (HoloHook.java:35-50). → Валидация аргументов.
- **88. PAPI expansion id = `"witp"`** (PAPIHook.java:27) — наследие форка «Walk in the Park» (оригинального Infinite Parkour), не совпадает с документацией/скрорбордом. → Переименовать в `loparkour` (см. п.118).
- **89. `VaultHook` кэширует Economy навсегда** (VaultHook.java:26-35) — после reload экономики ссылка битая. → Re-resolve при каждом использовании/слушать ServiceEvent.
- **90. `Probs.random()` на пустой карте — NoSuchElementException** (Probs.java:34-48; достижимо при инвертированных min>max или невалидных special-материалах); все нулевые веса → всегда первый элемент. → Empty-check + отсев нулевых весов.
- **91. `ParticleUtil.box` — бесконечный цикл при `spacing<=0`** (ParticleUtil.java:36-55). → Guard.
- **92. `Locations.max/min` и `SkullSetter` — мёртвый код**; `Item.clone()` теряет meta. → Удалить/починить.
- **93. `en.yml:258` — висячий плейсхолдер `server.ip` в скорборде**. → Убрать или подставить.
## ⚪ [P4] Низкое / мелочи
- **94. Миграция схем при загрузке нерекурсивна** и пишет `schematics.yml` при каждом конверте (LpschemConverter.java:63-84).
- **95. `SchematicIdGenerator` хеширует только byte-координаты → коллизии id** (SchematicIdGenerator.java:23-28).
- **96. `parseDifficulty` принимает NaN/Infinity** (SchematicCreator.java:64-70) → схема навсегда невыбираема.
- **97. `stem()` регистрирует файл `.nbt` под пустым id**; `.NBT` игнорируется, `.schematic` — case-insensitive (SchematicManager.java:147-150,88).
- **98. `Locales.getPlayerLocale`/`ParkourUser.locale` deref `OPTIONS_DEFAULTS.get(LANG)` без null-check** (Locales.java:170-184, ParkourUser.java:64).
- **99. Магические числа**: fall-threshold -30 (LifecycleTickManager.java:72), render 24.0/0.1/%4 (InvisibleBarrierMode.java:67-68,98), 10/30 (GravityShiftMode.java:82,108), 100 тиков (RaceMode.java:147), hotbar delay 5 (ParkourPlayer.java:192), `TICKS_PER_SECOND=20` переопределён (SpeedrunMode.java:82). → В конфиг/константы.
- **100. Deprecated API**: `Material.STONE_SLAB` (MaterialUtil.java:20), `registerNewObjective(String,String,String)` (ScoreboardManager.java:75), `getOfflinePlayer(name)` (AdminCommandHandler.java:186-199), legacy ACTION_BAR (ParkourSpectator.java:73, RaceMode.java:103).
- **101. `ParkourSpectator.java:96` — бессмысленный double-set spectator target** (no-op).
- **102. `GeneratorStatistics.registerScore` пишет в лидерборд на каждого игрока при каждом падении** в мультиплеере (GeneratorStatistics.java:46-55).
- **103. `GeneratorProfileManager.calculateSpecialChances` — пустой `catch(Exception ignored)`** (GeneratorProfileManager.java:154) → бесшумно отключает special-блоки; magic-веса 0.3/0.2/0.5 (57-67).
- **104. `ProfileValue.asDouble/asInt` бросают NFE на нечисловых строках** (Profile.java:54,64) — только empty-guard.
- **105. Дубликат логики `create()` во всех режимах** с несогласованными проверками дублей (instanceof DefaultMode vs instanceof SpeedrunGenerator).
- **106. Мёртвый код**: `EffectManager` целиком, `SessionUserManager`, `SQLMigrationManager`, `SQLQueryExecutor`, `SkullSetter`, `Locations.max/min`, `ParkourBlockGenerateEvent`, `onPlayerJoin` (CoopMode.java:169), `PLAYER_COLUMNS`/`OptionContainer` (ParkourPlayer.java:38,205), `acceptingPlayers`-механизм, `Muted` в двух местах.
- **107. Публичные mutable поля-государство**: `GeneratorState` — 15 публичных полей, `ParkourGenerator.session/island/profile/state/generatorOptions` публичные final, `Score` времени в строке. → Инкапсуляция.
## 🧪 Тесты
- **108. Расширить покрытие** (сейчас 4 класса, 398 строк): минимум — `Score.getTimeMillis`, `JumpCalculator/JumpValidator` (включая retry-exhaustion и диагональ), `ConfigUpdater.merge` (листы/комментарии/пропуски), `Probs.random` (пустые/нулевые веса), `Reward` (vault-кейс, `%player%` с `$`), `SQLDataMapper` (UUID/настройки), `PreviousData` restore.
- **109. Проверять Folia-инварианты в CI** (запрет BukkitRunnable/global-мутаций) — статический анализ или правило checkstyle.
## 📊 Конкуренты — куда догонять
- **110. Режимы**: IP-Reborn имеет 8 рабочих режимов (Practice/Speed/SuperJump/Hourglass/TimeTrial/Wave/TeamSurvival/Duels) + elytra. У нас 5 работают, coop мёртв, elytra удалён. → Довести coop до рабочего состояния или убрать из рекламы; рассмотреть TimeTrial/Hourglass.
- **111. Мультиплеер-лобби с инвайтами** — у IP-Reborn есть, у нас нет (только dead coop). → Реализовать invite-флоу.
- **112. Void-генератор**: IP-Reborn встроил native void-gen (не нужен VoidGen). У нас зависимость от VoidGen в softdepend и внешнего мира. → Встроить void-gen.
- **113. 6 типов прыжков (ice/slab/fence/glass/schematic)** — у нас slab/ice/schematic есть, fence/glass-pane через jump-types (мёртвый конфиг). → Догнать типологию прыжков.
- **114. Документация/вики + Discord** — у конкурентов полноценная вики. → Сделать документацию по config.yml, generation.yml, rewards-v2.yml, локалям, API.
- **115. Версионность**: конкурент держит 1.7–26.x. Наш api-version 1.20, Java 21+. → Определить целевые версии и CI-тесты на них.
- **116. Инвентарь-recovery как фича поддержки** — вынести в команду и проверять (у нас есть, но с гонками потоков, см. InventoryData.java:50-57).
### Наследие форка (LoParkour = форк «Walk in the Park» / Infinite Parkour Efnilite)
- **117. Удалить формат `.lpschem` полностью, оставить только `.schem` (WorldEdit) + `.nbt`.** Выпилить: пакет `schematic/legacy/lpschem/`, `LpschemConverter`, конвертер-команду, `LPSchematicLegacy`, примеры `*.lpschem` из `resources/schematics/`, все ветки `.lpschem` в `SchematicManager`/`SchematicCreator`. Формат устарел, это двойная поддержка (аудита: п.31, 53-56, 94-97) — один формат схем, один путь пасты/валидации.
- **118. Переименовать PAPI-id `witp` → `loparkour`** (наследие оригинала; также поменять примеры в scoreboard/docs).
- **119. Command block support** — у IP и IP-Reborn есть (запуск команд `/parkour` из командного блока), у нас нет. → Поддержать `CommandBlock` sender в `LoParkourCommand`.
- **120. Расширить PAPI-плейсхолдеры до 15+** (у IP 15+): score, record, время, режим, ранк, стиль, скорость, статистика адаптива и т.д.
- **121. «No-lag scoreboard»** — у IP заявлен, у нас скорборд пересобирается каждый тик (LifecycleTickManager.java:61, ScoreboardManager.update). → Обновлять title/lines только при изменении значений (debounce/версия).
### Таблица сравнения (полная)
| Возможность | LoParkour 1.3.3 | Ориг. Infinite Parkour 5.3.1 (заброшен, 10.2024) | IP-Reborn 6.0 | ajParkour 2.12 (free) | Parkour (A5H73Y) 7.2.8 |
|---|---|---|---|---|---|
| Бесконечная генерация | ✅ | ✅ (6 типов) | ✅ (6 типов) | ✅ в зоне (WorldEdit) | ❌ (карты/чекпоинты) |
| Режимы | 7 заявлено, coop мёртв | ✅ | 8 + elytra | 5 уровней difficulty | ❌ |
| Ghost-реплей | ❌ мёртвый код | ❌ | ❌ | ❌ | ❌ |
| Схематики | ✅ (.lpschem/.schem, крешит) | ✅ | ✅ | ❌ | ✅ паркур-блоки |
| Лидерборды | ✅ (TIME сломан) | ✅ | ✅ | ✅ top-10 | ✅ время/смерти/уровень |
| Storage | YAML+MySQL (утечки) | Local+MySQL | Local+MySQL | YAML+MySQL | YAML+MySQL |
| Inventory recovery | ✅ частично | ✅ | ✅ | ✅ | ❌ |
| Мультиплеер-лобби | ❌ coop мёртв | ❌ (IPPlus отдельно) | ✅ сессии/инвайты | ❌ | ❌ |
| PAPI / Vault / HD / Floodgate | ✅ (id witp) | ✅ 15+ PAPI | ✅ | ✅ | ✅ широчайшие |
| Command block | ❌ | ✅ | ✅ | ❌ | ❌ |
| Переводы | en/ru, без fallback | en/nl + custom | en/zh + custom | messages.yml | strings.yml |
| Dev API | ✅ события + Registry | ✅ | ✅ | ❌ | ✅ лучшая документированность |
| Folia | ❌ заявлено, не работает | ❌ | ❌ честно отключено | ❌ | ❌ |
| Версии | 1.19.2+ / Java 21 | 1.16–1.21 | 1.21.11–26.x / Java 21-25 | 1.7.10–26.x | 1.7–26.x |
| Актуальность | 2026, 1.3.3 | заброшен | v6, активный | 178 обновлений | с 2012 |
| Документация | минимальная | вики (осталась) | вики + дискорд | вики | полная вики |
> Ссылки: IP-Reborn — https://www.spigotmc.org/resources/infinite-parkour-reborn-3-in-1.136046/ + https://github.com/LostUmbrella58/IP-Reborn ·
> Ориг. IP — https://www.spigotmc.org/resources/87226/ + https://github.com/Efnilite/Walk-in-the-Park
---
## 🆕 Новые режимы (анализ исходников IP-Reborn в `ref/IP-Reborn-main/`)
### Duels (перенос из `dev.efnilite.ipp`)
- **122. Duels — модель «генератор на игрока».** Сейчас наш `ParkourGenerator` = один скорер на сессию (общий `GeneratorState`/history/score, `PlayerInteractionHandler.checkAllPlayers` скорит всех по одной трассе) — это несовместимо с дуэлью. Схема IP-Reborn: `DuelsGenerator` (владелец сессии) держит `Map<ParkourPlayer, SingleDuelsGenerator>`, каждый `SingleDuelsGenerator extends ParkourGenerator` со своим state/history/time/цветом/спавн-островом, разнесены по Z. Победа = первый набравший `goal`. Результаты НЕ пишутся в лидерборд (`getLeaderboard()=null`). Тик идёт только через владельца.
- **123. Duels — изменения ядра:** ослабить `final class Island` (или строить арены вручную), дать хук `selectBlockData()` (фиксированный цвет на игрока), пер-игроковая спавн-площадка со сдвигом.
- **124. Duels — классы:** `DuelsMode implements MultiMode` (create() реально создаёт сессию, join() через `isAcceptingPlayers`, **leave() только `removePlayer` без `ParkourUser.leave`** — фикс рекурсии Coop), `DuelsGenerator` (owner/allowJoining/playerGenerators/spawnData/goal; `initCountdown()` по клику LIME_BANNER 10с; `win()` с guard `stopped`; `reset(false)` синхронно и идемпотентно, НЕ `runLater`), `SingleDuelsGenerator` (`getPlayers()=List.of(player)`, `setPlayerIndex`→цвет, `score()`→`win` при goal).
- **125. Duels — интеграция:** `modes.duels{enabled,max:2,target-score,island-distance}` в config.yml, локали `modes.duels.*`/`play.multi.*`, `MultiplayerMenu` (фильтр `instanceof MultiMode`) + кнопка в PlayMenu, баннер-старт в hotbar, `/lp create|join <player>|invite`. Сложность: **L**.
- **126. Duels — не повторить баги:** Race (TIME-лидерборд, ресет в воздухе через `runLater`) — у Duels лидерборда нет, время брать из `state.start` своим форматтером, ресет только синхронно с null-guard; Coop (рекурсия, невозможность создать) — не вызывать `ParkourUser.leave` поверх unregister, сессию создавать в `create()`.
### Elytra (порт из `dev.efnilite.iep`, Kotlin → Java)
- **127. Elytra — модель.** Один генератор на игрока (не сессия на многих). Трасса = секция из 5 узлов (`KnotDirector`: dx~N(75,15), dy bias −15/+15 (ASCEND при Y<50), dz~N(0,35)), узлы → кубический сплайн → точки → «кольца» (Брезенхэм, `PointType.CIRCLE(-1)` / `FLAT(-5)` для CloseMode). Очки = пройденное расстояние: каждый тик `movementScore += velocity.x` при `isGliding && x > blockSpawn.x`. Фолл: Y<спавн−radius | дальше radius от точек | не глидишь. Подъёмная секция = фейерверк-буст (`velocity = v*0.7+dir*0.6`, насильный `isGliding`).
- **128. Elytra — клиент-сайд рендер.** `ClientBlockChanger` НЕ ставит блоки в мир: очередь `Map<chunkX, Set<Vector>>`, каждый тик шлёт игроку `player.sendBlockChanges(states)` (MultiBlockChange) только для чанков вперёд по X (limit min(viewDistance,8)). Пакет `ElytraBlockChanger` на Java.
- **129. Elytra — классы:** `ElytraSection`+`ElytraKnotDirector`+`ElytraPointType`, `ElytraGenerator extends ParkourGenerator` (СВОЙ `tick()`, переопределённый `getMode()`, свой фолл-детект — не полагаться на `LifecycleTickManager.checkPlayerFall`), режимы `ElytraMode/Default/SpeedDemon/MinSpeed/TimeTrial/Close/Obstacle` (по одному подклассу генератора), раздача элитр в слот нагрудника + `ParkourHotbar`, `PreviousData` переиспользуем.
- **130. Elytra — зависимости/мир.** Добавить Apache Commons Math (`SplineInterpolator`) или заменить на Catmull-Rom. Отдельный мир не нужен: труба клиент-сайд, наш мир void; подобрать высоту/порог ASCEND под наши zone-границы. Скор в double (наши `Score.score` int — масштабировать или добавить float-вариант). Сложность: **S** базовый, **M** режимы+бусты, **L** Obstacle+настройки+стили+награды.
- **131. Тесты под новые режимы:** сплайн/секции (изоляция, Y/Z интерполяция), `ElytraGenerator.tick` (score по velocity.x, shouldReset-причины), `DuelsGenerator.win` (guard `stopped`, идемпотентность `reset(false)`), `SingleDuelsGenerator.score`→win при goal, рекурсия unregister (регрессия Coop).
---
## 🧹 Зачистка варнингов линтеров (добавлено после ужесточения конфигов)
Checkstyle отработал `0 errors` (error-уровневые legacy-нарушения `IllegalCatch` / `NPathComplexity` / `WhitespaceAround` закрыты супрессиями в `config/checkstyle/suppressions.xml`), но остаётся **1429 warning**. PMD — **45 нарушений** (report-only, `isIgnoreFailures=true`). Статистика по файлам — в `build/reports/checkstyle/main.xml` и `build/reports/pmd/main.xml`.
> ## ✅ ПРОГРЕСС (этап «декомпозиция god-классов» ЗАВЕРШЁН)
> Документ структуры: `docs/ARCHITECTURE.md`. Все 11 god-классов >200 строк декомпозированы
> (супрессии FileLength сняты, **каждый файл ≤200 строк**): LoParkourCommand→CommandRouter/CommandTabCompleter/
> JoinCommandExecutor/LeaderboardCommandExecutor/CommandUtil; SchematicCommandHandler→SchematicDispatcher/SchematicHandler/
> SchematicWand/SelectionStore/SchematicMessenger; ConfigUpdater→ConfigMergeParser/ConfigMergeProcessor;
> BlockPlacer→BlockTargetSelector/BlockWorldPlacer/SchematicBlockPlacer; InvisibleBarrierMode→BarrierGenerator/BarrierRenderer;
> PAPIHook→PlaceholderDispatcher&Resolvers&Formatters; AdminCommandHandler→ForceJoin/ForceLeave/Reset/RecoverInventory/
> AdminTargetResolver/AdminResponses; MetricsCollector→MetricsCacheStore/MetricEventCollector/JumpTimingTracker;
> StorageSQL→SQLSchemaManager/SQLScoreRepository/SQLPlayerRepository; ParkourPlayer→ScoringTracker/Persistence/SpawnSetup;
> Leaderboard→LeaderboardRecords.
> Актуальные счётчики: **checkstyle 0 errors / 1413 warnings**, **PMD 34**. Пункты 132–138 — в силе.
>
> ## ✅ ПРОГРЕСС-2 (правило «одна папка = одна цель» — пилоты применены)
> Правило добавлено в `docs/ARCHITECTURE.md` (п.8 «Принципы проектирования»). Пилоты: `command/`
> → core/ + util/ (player/ уже был), `mode/impl/` → solo/ + multi/ + barrier/, `hook/papi/` → resolver/.
> Классы, пересекающие пакеты, стали public (сигнатуры не менялись). Остальные переполненные папки
> (storage/sql, adaptive/core, api/event, menu/core, config/core|options, schematic/legacy/lpschem,
> player/service, generator/*, util/gui и др.) — кандидаты на аналогичное разбиение по под-целям.
- **132. [C] Почистить `FinalLocalVariable` (legacy, ~1034 warning / 120 файлов).** Объявить локальные переменные `final`. Делать вместе с декомпозицией, а не отдельным проходом.
- **133. [C] Почистить `MagicNumber` (legacy, ~346 warning / 60 файлов).** Заменить магические числа на именованные константы (приоритет: конфиг- и стиль-коэффициенты, пороги сложности, тайминги).
- **134. [M] Починить `WhitespaceAround` (legacy, 30 / 14 файлов).** Авто-формат либо ручная правка по отчёту.
- **135. [M] Починить `NPathComplexity` (legacy, 29 / 25 файлов).** Решается декомпозицией методов (файлы и так в списке на рефакторинг).
- **136. [M] Починить `IllegalCatch` (legacy, 22 / 19 файлов).** catch(Exception/Throwable/RuntimeException)→сужение до конкретных исключений либо переименовать в `expected|ignore`.
- **137. [M] Починить все нарушения PMD (45):** `CognitiveComplexity(17)` + `CyclomaticComplexity(7)` — декомпозиция; `GodClass(4)` + `TooManyMethods(8)` — декомпозиция; `AvoidReassigningParameters(7)` — переименование параметров; `EmptyCatchBlock(2)` — комментарий/конкретный тип.
- **138. [S] Перевести PMD из report-only в жёсткий гейт.** После зачистки убрать `isIgnoreFailures=true` в `build.gradle.kts` (pmd-блок).
После зачистки п.132–137 — удалить соответствующие записи из `config/checkstyle/suppressions.xml` (супрессии остаются только для ещё незачищенных legacy-файлов).
---
## Итог по аудиту
- Найдено: **~125 новых пунктов** (P1: 18, P2: 21, P3: 28, P4: 14, тесты: 2, конкуренты: 13, наследие форка: 5, новые режимы: 10, зачистка варнингов: 7).
- Мёртвые фичи: ghost (п.22), coop (п.23-24), jump-types/EffectManager (п.38).
- Мёртвый конфиг: `jump-validation`, `jump-types`, `ghost-mode`, `useSpecialBlocks`, `blockLead`.
- Главные риски производства: Folia-краш, утечка SQL-пулов, потеря данных в ConfigUpdater, креш от битых схем.

View file

@ -3,6 +3,7 @@ plugins {
id("io.github.goooler.shadow") version("8.1.8")
id("xyz.jpenilla.run-paper") version("3.0.2")
checkstyle
pmd
}
group = "dev.loki"
@ -174,7 +175,18 @@ checkstyle {
configFile = file("config/checkstyle/checkstyle.xml")
isIgnoreFailures = false
maxErrors = 0
maxWarnings = 0
// Warnings are NOT blocking: noisy/opinionated rules (MagicNumber,
// FinalLocalVariable, EmptyLineSeparator, ClassFanOutComplexity, ...) run at
// severity="warning" in checkstyle.xml and surface in build/reports/checkstyle/.
}
pmd {
toolVersion = "7.0.0"
ruleSetFiles = files(rootProject.file("config/pmd/ruleset.xml"))
// PMD is currently report-only (see build/reports/pmd/). It is intentionally
// NOT failing the build until the legacy god-classes (ParkourGenerator,
// ParkourPlayer, etc.) are decomposed. New duels/elytra code must be clean.
isIgnoreFailures = true
}
tasks.withType<Checkstyle>().configureEach {
@ -184,5 +196,14 @@ tasks.withType<Checkstyle>().configureEach {
}
}
tasks.withType<Pmd>().configureEach {
reports {
xml.required.set(true)
html.required.set(true)
}
// Ignore generated/shaded sources and tests for now (test sources are not analyzed).
exclude("**/build/generated/**")
}
// Default task
defaultTasks("clean", "build")

View file

@ -12,6 +12,19 @@
<property name="fileNamePattern" value=".*[\\/]src[\\/]test[\\/].*"/>
</module>
<!-- Max file size: enforces the 200-line-per-file rule (KISS / decoupling). -->
<module name="FileLength">
<property name="max" value="200"/>
</module>
<!-- Legacy files that still exceed 200 lines. Remove entries as they get
decomposed; new files must already comply and must NOT be added here. -->
<module name="SuppressionFilter">
<property name="file"
value="${config_loc}/suppressions.xml"/>
<property name="optional" value="true"/>
</module>
<module name="SuppressWarningsFilter"/>
<module name="LineLength">
@ -85,5 +98,67 @@
<module name="NoFinalizer"/>
<module name="NoClone"/>
<module name="PackageDeclaration"/>
<!-- ============ Complexity and size ============ -->
<module name="NPathComplexity">
<property name="max" value="50"/>
</module>
<module name="NestedIfDepth">
<property name="max" value="3"/>
</module>
<module name="NestedForDepth">
<property name="max" value="2"/>
</module>
<module name="NestedTryDepth">
<property name="max" value="2"/>
</module>
<module name="ClassDataAbstractionCoupling">
<property name="max" value="7"/>
<property name="severity" value="warning"/>
</module>
<module name="ClassFanOutComplexity">
<property name="max" value="10"/>
<property name="severity" value="warning"/>
</module>
<!-- ============ Safety and reliability ============ -->
<module name="MagicNumber">
<property name="ignoreNumbers" value="-1, 0, 1, 2"/>
<property name="ignoreHashCodeMethod" value="true"/>
<property name="ignoreAnnotation" value="true"/>
<property name="severity" value="warning"/>
</module>
<module name="Regexp">
<property name="format" value="System\.(out|err)\.print"/>
<property name="illegalPattern" value="true"/>
<property name="message" value="Use a logger instead of System.out/err"/>
</module>
<module name="Regexp">
<property name="format" value="\.printStackTrace\(\)"/>
<property name="illegalPattern" value="true"/>
<property name="message" value="Use a logger instead of printStackTrace"/>
</module>
<module name="IllegalThrows"/>
<module name="IllegalCatch">
<property name="illegalClassNames"
value="java.lang.Exception, java.lang.Throwable, java.lang.RuntimeException"/>
</module>
<!-- ============ Style and maintainability ============ -->
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
<property name="severity" value="warning"/>
</module>
<module name="WhitespaceAround"/>
<module name="WhitespaceAfter"/>
<module name="NoWhitespaceBefore"/>
<module name="MissingOverride"/>
<module name="MissingDeprecated">
<property name="severity" value="warning"/>
</module>
<module name="FinalLocalVariable">
<property name="validateEnhancedForLoopVariable" value="true"/>
<property name="severity" value="warning"/>
</module>
</module>
</module>

View file

@ -0,0 +1,81 @@
<?xml version="1.0"?>
<!DOCTYPE suppressions PUBLIC
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
<!--
Suppressions for legacy files that violate the stricter rules:
- FileLength (200-line limit),
- IllegalCatch (no catch of Exception/Throwable/RuntimeException),
- NPathComplexity (<= 50) and WhitespaceAround.
Legacy files are scheduled for decomposition / cleanup (see docs/ARCHITECTURE.md).
Remove each entry once the file complies on its own.
New files must already comply with ALL rules and must NOT be added here —
EXCEPT two documented defensive boundary catches (see below).
NOTE: the noisy MagicNumber/FinalLocalVariable rules are kept at
severity="warning", so they do not need suppressions.
-->
<suppressions>
<!-- ===== FileLength (200 lines) ===== -->
<!--
===== Intentional defensive boundary catches =====
These are NOT swallowing exceptions:
- LoParkourCommand.onCommand: catches Throwable at the command entry point,
logs the full stack trace and notifies the player (never silent).
- ConfigUpdater: catches Exception to roll back to the backup copy and
rethrows — transactional semantics, the exception is not swallowed.
Do not remove without a review of the intended behavior.
-->
<suppress checks="IllegalCatch" files=".*[\\/]command[\\/]core[\\/]LoParkourCommand\.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]config[\\/]core[\\/]ConfigUpdater\.java"/>
<!-- ===== IllegalCatch / NPathComplexity / WhitespaceAround (legacy debt) ===== -->
<suppress checks="IllegalCatch" files=".*[\\/]LoParkour.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]adaptive[\\/]bootstrap[\\/]AdaptiveServices.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]bootstrap[\\/]PluginBootstrap.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]command[\\/]player[\\/]PlayerCommandHandler.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]config[\\/]core[\\/]ConfigLoader.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]config[\\/]locale[\\/]LocaleCache.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]config[\\/]locale[\\/]LocaleLoader.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]config[\\/]options[\\/]OptionGeneral.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]generator[\\/]core[\\/]coordinator[\\/]GeneratorProfileManager.java"/>
<suppress checks="WhitespaceAround" files=".*[\\/]generator[\\/]core[\\/]model[\\/]GeneratorState.java"/>
<suppress checks="WhitespaceAround" files=".*[\\/]generator[\\/]effect[\\/]EffectManager.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]generator[\\/]jump[\\/]calculation[\\/]JumpDirector.java"/>
<suppress checks="IllegalCatch|NPathComplexity" files=".*[\\/]generator[\\/]jump[\\/]calculation[\\/]JumpValidator.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]generator[\\/]jump[\\/]placement[\\/]BlockSelector.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]generator[\\/]lifecycle[\\/]loop[\\/]LifecycleEventManager.java"/>
<suppress checks="NPathComplexity|WhitespaceAround" files=".*[\\/]generator[\\/]lifecycle[\\/]player[\\/]GeneratorCleanup.java"/>
<suppress checks="WhitespaceAround" files=".*[\\/]hook[\\/]floodgate[\\/]FloodgateHook.java"/>
<suppress checks="IllegalCatch|NPathComplexity|WhitespaceAround" files=".*[\\/]hook[\\/]holo[\\/]HoloHook.java"/>
<suppress checks="WhitespaceAround" files=".*[\\/]hook[\\/]vault[\\/]VaultHook.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]leaderboard[\\/]core[\\/]LeaderboardSorter.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]listener[\\/]gameplay[\\/]ParkourRestrictionListener.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]listener[\\/]schematic[\\/]SchematicWandListener.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]menu[\\/]community[\\/]LeaderboardsMenu.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]menu[\\/]community[\\/]SingleLeaderboardMenu.java"/>
<suppress checks="WhitespaceAround" files=".*[\\/]menu[\\/]core[\\/]DynamicMenu.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]menu[\\/]core[\\/]MainMenu.java"/>
<suppress checks="WhitespaceAround" files=".*[\\/]menu[\\/]core[\\/]MenuStub.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]menu[\\/]lobby[\\/]LobbyMenu.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]menu[\\/]lobby[\\/]PlayerManagementMenu.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]menu[\\/]play[\\/]SingleMenu.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]menu[\\/]play[\\/]SpectatorMenu.java"/>
<suppress checks="WhitespaceAround" files=".*[\\/]mode[\\/]impl[\\/]solo[\\/]GravityShiftMode\.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]player[\\/]data[\\/]InventoryData.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]player[\\/]data[\\/]PreviousData.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]player[\\/]service[\\/]ScoreboardManager.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]player[\\/]service[\\/]UserRegistry.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]reward[\\/]core[\\/]Reward.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]session[\\/]manager[\\/]SessionStateManager.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]storage[\\/]sql[\\/]SQLConnectionManager.java"/>
<suppress checks="WhitespaceAround" files=".*[\\/]storage[\\/]sql[\\/]SQLDataMapper.java"/>
<suppress checks="NPathComplexity|WhitespaceAround" files=".*[\\/]util[\\/]item[\\/]Materials.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]world[\\/]core[\\/]World.java"/>
</suppressions>

46
config/pmd/ruleset.xml Normal file
View file

@ -0,0 +1,46 @@
<?xml version="1.0"?>
<ruleset name="LoParkour PMD rules"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 http://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>
PMD rules complementing checkstyle. Adds checks checkstyle does not
cover: resource leaks, string ==, empty control flow, god-class/size
hints and complexity.
Currently report-only; becomes a hard gate after the SRP refactor.
UnusedImports is excluded (handled by checkstyle).
</description>
<!-- ===== bugs / error-prone ===== -->
<!-- Empty if/while/finally blocks are covered by checkstyle (EmptyBlock,
EmptyStatement, EmptyCatchBlock); PMD 7 has no generic replacement. -->
<rule ref="category/java/errorprone.xml/EmptyCatchBlock"/>
<rule ref="category/java/errorprone.xml/UseEqualsToCompareStrings"/>
<rule ref="category/java/errorprone.xml/CloseResource"/>
<rule ref="category/java/errorprone.xml/UnusedNullCheckInEquals"/>
<!-- ===== dead code ===== -->
<rule ref="category/java/bestpractices.xml/UnusedPrivateMethod"/>
<rule ref="category/java/codestyle.xml/UnnecessaryImport"/>
<rule ref="category/java/bestpractices.xml/AvoidReassigningParameters"/>
<!-- ===== complexity ===== -->
<rule ref="category/java/design.xml/CyclomaticComplexity">
<properties>
<property name="methodReportLevel" value="15"/>
</properties>
</rule>
<rule ref="category/java/design.xml/CognitiveComplexity">
<properties>
<property name="reportLevel" value="15"/>
</properties>
</rule>
<!-- ===== god-object / size hints (mirrors the 200-line file rule;
line count itself is hard-gated by checkstyle FileLength) ===== -->
<rule ref="category/java/design.xml/GodClass"/>
<rule ref="category/java/design.xml/TooManyFields"/>
<rule ref="category/java/design.xml/TooManyMethods"/>
</ruleset>

250
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,250 @@
# Целевая архитектура LoParkour
> Руководство для последующих рефакторингов god-классов и добавления новых режимов (Duels, Elytra).
> Документ описывает **целевое** состояние; текущее зафиксировано в разделе «Текущее состояние».
---
## Принципы проектирования
1. **KISS** — минимум слоёв и абстракций для решения задачи; каждый класс делает одну вещь.
2. **DRY** — одна реализация фичи в одном месте; дублирование выносится в общий переиспользуемый слой
(напр. рендеринг/размещение блоков — в `block/`).
3. **SOLID**:
- **SRP** — у класса ровно одна ответственность (главный драйвер декомпозиции god-классов);
- **OCP** — новые режимы добавляются новыми подклассами/реализациями, без правки существующих;
- **ISP** — узкие интерфейсы (`Mode`, `MultiMode`, `GeneratorEventListener`);
- **DIP** — домен зависит от абстракций, инфраструктура зависит от домена.
4. **Композиция вместо наследования** — god-классы разбиваются на коллабораторов, инжектируемых в тонкий
фасад (паттерн уже применён в `ParkourGenerator`, `BlockPlacer`, `StorageSQL`, `Leaderboard` — развиваем его).
5. **Границы размера** (жёсткие, зафиксированы в `config/checkstyle/checkstyle.xml`, severity=`error`):
- файл ≤ **200 строк** (`FileLength`), метод ≤ **80 строк** (`MethodLength`),
параметров ≤ **7** (`ParameterNumber`), линия ≤ **150 символов** (`LineLength`),
`NPathComplexity` ≤ **50**, цикломатическая сложность ≤ **20**, вложенность if/for/try ≤ 3/2/2.
6. **Явный порядок зависимостей** — слои смотрят «вниз», циклы запрещены:
`api/` → `core/` (домен) → `block/` + subsystems (`generator/`, `schematic/`, `adaptive/`, `ghost/`,
`style/`, `world/`, `leaderboard/`, `reward/`, `duels/`, `elytra/`) → платформа
(`storage/`, `config/`, `hook/`, `listener/`, `command/`, `menu/`, `util/`).
7. **Новые файлы обязаны соответствовать всем правилам** — их нельзя добавлять в `suppressions.xml`.
Супрессии — только временный долг существующих legacy-файлов, удаляются по мере рефакторинга.
8. **Одна папка = одна цель.** Каждый пакет содержит файлы ОДНОЙ ответственности (пример нормы:
`command/schematic/` — все 6 файлов про схематик-команды). Если в папку попадают разные по смыслу
классы — режем на подпапки по под-целям (пример: `util/gui` — это часть подсистемы меню, а не общий
util; `mode/impl/` — соли-режимы, мульти-режимы и барьеры — разные цели). Ориентир: ~3-5 файлов на
папку; папка из 1 файла допустима, если это самостоятельная единица (util, model, msg), но НЕ ради
искусственной нарезки.
## Текущее состояние (кратко)
- **173 Java-файла** в `src/main/java/dev/loki/loparkour/` (база пакета `dev.loki.loparkour`), Java 21,
Paper 1.20.4 / Spigot API + Adventure, сборка shadow + checkstyle + PMD.
- Декомпозиция по паттерну «фасад + коллабораторы» уже частично проведена (`ParkourGenerator`,
`BlockPlacer`, `StorageSQL`, `Leaderboard`, `SessionStateManager`), но фасады и часть режимов всё ещё
превышают лимит 200 строк.
**God-классы (>200 строк) и где они «протекают»:**
| Класс | Строк | Протекающие обязанности |
|---|---|---|
| `command/LoParkourCommand` | 302 | роутинг по аргументам (0..4) + join/leaderboard логика + весь tab-complete |
| `command/schematic/SchematicCommandHandler` | 274 | сабкоманды схем + per-player кэш `SELECTIONS` |
| `config/core/ConfigUpdater` | 229 | шаблон-merge, YAML-парсинг full-path, бэкап/восстановление |
| `generator/jump/placement/BlockPlacer` | 227 | выбор BlockData, расчёт прыжка, запись блоков, эффекты, паста схем |
| `mode/impl/InvisibleBarrierMode` | 222 | `Mode`-фасад + `BarrierGenerator` + отрисовка контуров частицами |
| `hook/papi/PAPIHook` | 220 | метаданные expansion + роутинг + форматтеры рангов/сложности |
| `command/admin/AdminCommandHandler` | 214 | forcejoin/forceleave/reset/recoverinventory + резолв игроков |
| `adaptive/core/MetricsCollector` | 214 | метрики, near-miss-анализ, автосейв, кэш игроков |
| `storage/sql/StorageSQL` | 209 | инициализация пула, DDL, read/write скоров, read/write игрока |
| `player/core/ParkourPlayer` | 207 | Expose-настройки, скоринг-сет, статические реестры, сохранение, setup/hotbar |
| `leaderboard/core/Leaderboard` | 203 | CRUD, сортировка, тай-брейк, снапшоты рангов |
| `mode/impl/CoopMode` | 200 | на границе: `MultiMode` + `CoopGenerator` (contributions/милстоуны) |
Сопутствующие риски из `TODO.md`, влияющие на декомпозицию: статические mutable-поля
(`GeneratorState` — 15 публичных полей, `ParkourGenerator.session/island/profile/state/generatorOptions` —
п.107), рекурсия `unregister`/Coop (п.24), утечка SQL-пулов (п.27), per-player GUI-состояние (п.78),
неатомарный reload (п.70).
## Целевая структура пакетов
Новые режимы выносятся в **отдельные корневые пакеты** `duels/` и `elytra/` (не в существующий `mode/impl/`).
Общий переиспользуемый рендеринг/размещение блоков — в **`block/`**. Ядро домена — в **`core/`**.
Существующие пакеты сохраняются и встраиваются в слои.
```text
dev.loki.loparkour
│
├── api/ # Публичный контракт для внешних плагинов (единственный «экспортный» слой)
│ ├── core/ # ParkourAPI, Registry — регистрация режимов/стилей/событий
│ └── event/ # Доменные события (join/leave/score/fall/schematic/spectate/block-generate)
│
├── core/ # ЯДРО ДОМЕНА: агрегаты и доменные сервисы (слой ниже api/)
│ ├── model/ # Неизменяемые доменные модели (Score, Reward, GhostData и т.п.)
│ ├── player/ # Агрегат игрока: ParkourPlayer, ParkourUser, ParkourSpectator
│ ├── session/ # Session + жизненный цикл сессии (create/join/leave/removePlayers)
│ ├── mode/ # Контракты режимов: Mode, MultiMode, Modes
│ └── service/ # Доменные сервисы (UserRegistry, ParkourHotbar, ScoreboardManager)
│
├── block/ # ОБЩИЙ слой размещения/рендеринга блоков (для ВСЕХ генераторов и режимов)
│ ├── engine/ # BlockWriter: запись BlockData в мир + BlockEffectPlayer (particles/sound)
│ ├── selection/ # BlockSelector: выбор BlockData по стилям/весам (хук selectBlockData для режимов)
│ ├── style/ # BlockStyleProvider: материал/цвет/сдвиг блока по стилю
│ ├── jump/ # JumpCalculator, JumpValidator, JumpDirector, JumpOffsetGenerator, JumpType
│ ├── schematic/ # Паста схематик в мир (SchemPaster/StructurePaster, событие генерации)
│ ├── render/ # Частице-рендер: BlockOutlineRenderer, контуры/боксы (из InvisibleBarrierMode)
│ └── client/ # Клиент-сайд виртуальные блоки (без записи в мир) — использует Elytra
│
├── generator/ # SUBSYSTEM «бесконечная генерация» (сохраняется, использует block/)
│ ├── core/coordinator/ # ParkourGenerator (фасад), GeneratorProfileManager, GeneratorStatistics
│ ├── core/model/ # GeneratorState, Profile, Island, GeneratorOption
│ ├── jump/calculation/ # Расчёт прыжков (мигрирует в block/jump после стабилизации API)
│ ├── jump/placement/ # Оркестрация генерации блока (DEFAULT/SPECIAL/SCHEMATIC)
│ ├── lifecycle/loop/ # Тик/фолл/ресет/события генератора
│ ├── lifecycle/player/ # PlayerInteractionHandler, GeneratorCleanup
│ └── profile/ # GenerationProfileLoader (профили сложности)
│
├── schematic/ # SUBSYSTEM «схематики»: core/create/convert/nbt/schem/registry/legacy
├── adaptive/ # SUBSYSTEM «адаптивная сложность»: core, model, storage, bootstrap
├── ghost/ # SUBSYSTEM «ghost-реплей»: core (GhostManager/Recorder/Player), model
├── style/ # Визуальные темы: Style, RandomStyle
├── world/ # Зоны/острова: Divider, World
├── leaderboard/ # Лидерборды: core (Leaderboard/Sorter), model (Score), persistence
├── reward/ # Награды: Reward, Rewards
│
├── duels/ # НОВЫЙ РЕЖИМ «Duels» (корневой пакет, генератор на игрока)
│ ├── mode/ # DuelsMode implements MultiMode (create/join/leave, isAcceptingPlayers)
│ ├── generator/ # DuelsGenerator (owner) + SingleDuelsGenerator (генератор на игрока)
│ ├── match/ # goal, счёт, спавн-площадки, цвета игроков, initCountdown/win
│ └── menu/ # MultiplayerMenu / lobby invite (фильтр instanceof MultiMode)
│
├── elytra/ # НОВЫЙ РЕЖИМ «Elytra» (корневой пакет, клиент-сайд труба)
│ ├── mode/ # ElytraMode + вариации (Default/SpeedDemon/MinSpeed/TimeTrial/Close/Obstacle)
│ ├── section/ # ElytraSection, KnotDirector (сплайн/кольца), PointType
│ ├── render/ # Клиент-сайд рендер колец (sendBlockChanges)
│ └── generator/ # ElytraGenerator (свой tick()/fall-детект), фейерверк-буст
│
├── player/ # Существующий пакет (3-й слой, обслуживает core/player)
│ ├── core/ # ParkourPlayer, ParkourUser (агрегаты)
│ ├── data/ # PreviousData, InventoryData (save/restore состояния)
│ ├── service/ # ParkourHotbar, UserRegistry, ScoreboardManager, PlayerSettingsManager, BungeeUtil
│ └── spectator/ # ParkourSpectator
├── session/ # Session + Session*Manager (State/Player/User; тик пер-сессии)
├── mode/ # Существующие режимы: base/ (контракты), impl/solo|multi|barrier/
├── storage/ # Платформа хранения: Storage, disk/StorageDisk, sql/StorageSQL + SQL*
├── config/ # Платформа конфигурации: core, locale, options
├── hook/ # Платформенные интеграции: papi/ (+ resolver/), vault/, holo/, floodgate/
├── listener/ # Bukkit-листенеры: gameplay/, player/, schematic/
├── command/ # Команды: core/ (LoParkourCommand+Router+TabCompleter), util/, admin/, player/, schematic/
├── menu/ # GUI: core/, community/, lobby/, play/, settings/
└── util/ # Общие утилиты: gui/, item/, misc/, particle/, text/, world/
```
### Текущее состояние (после пилотной реструктуризации)
Правило «одна папка = одна цель» применено к 3 пилотным областям (остальные — в работе):
- **`command/`** — `core/` (LoParkourCommand, CommandRouter, CommandTabCompleter — вход/роутинг/табы),
`player/` (PlayerCommandHandler, JoinCommandExecutor, LeaderboardCommandExecutor),
`util/` (CommandUtil), `admin/`, `schematic/`.
- **`mode/impl/`** — `solo/` (DefaultMode, SpeedrunMode, GravityShiftMode), `multi/` (CoopMode, RaceMode,
SpectatorMode), `barrier/` (InvisibleBarrierMode, BarrierGenerator, BarrierRenderer).
- **`hook/papi/`** — `PAPIHook`+`PlaceholderDispatcher` (вход/диспатч), `resolver/` (Global/Player/ScoreRank
резолверы + PlaceholderFormatters).
Пересекающие пакеты классы стали `public` (JoinCommandExecutor, LeaderboardCommandExecutor, CommandUtil,
резолверы) — сигнатуры методов не менялись, только видимость.
## Карта миграции
Таблица «старый класс → решение» для god-классов. `split` — разделить на новых коллабораторов,
`keep` — оставить как есть, `move` — перенести ответственность в другой пакет без изменения поведения.
| Старый класс | Решение | Новые классы (пакет · ответственность) |
|---|---|---|
| `command/LoParkourCommand` | **split** | `command/CommandRouter` (диспетчер args.length 0..4) · `command/CommandContext` (sender/player/args-обёртка, сокращает параметры) · `command/JoinCommand` (join: режим/игрок/сессия) · `command/LeaderboardCommand` · `command/completion/CommandCompleter` (весь onTabComplete) · `command/completion/SchematicCompleter` + `ModeCompleter` (подкомандные) · `player/PlayerCommandHandler`, `admin/AdminCommandHandler`, `schematic/SchematicCommandHandler` — **keep** (уже выделены) |
| `command/schematic/SchematicCommandHandler` | **split** | `command/schematic/SchematicCommandRouter` (роутинг) · `.../SchematicCreateHandler` · `.../SchematicPasteHandler` · `.../SchematicWandSession` (per-player `SELECTIONS`, cleanup на quit — чинит п.84) · `.../SchematicConvertHandler` |
| `command/admin/AdminCommandHandler` | **split** | `command/admin/ForceJoinHandler` · `.../ForceLeaveHandler` · `.../ResetHandler` · `.../RecoverInventoryHandler` (по сабкоманде) · `command/admin/PlayerResolver` (resolveUUID/resolvePlayerName/findNearest; поддержка `BlockCommandSender` — п.119) |
| `config/core/ConfigUpdater` | **split** | `config/core/ConfigTemplate` (чтение шаблона) · `config/core/YamlPathParser` (full-path-парсинг, из `extractFullPathValues`) · `config/core/ConfigMerger` (точечный merge отсутствующих ключей, сохранение листов/комментариев — фикс п.26) · `config/core/ConfigBackup` (бэкап/восстановление) · `ConfigUpdater` = тонкий фасад (keep) |
| `generator/jump/placement/BlockPlacer` | **split+move** | `block/engine/BlockWriter` (placeBlockData, setBlockData+phys) · `block/engine/BlockEffectPlayer` (particles/sound на всех игроков) · `block/selection/BlockSelector` (**move**, уже есть) · `block/jump/JumpCalculator` (**move**, уже есть) · `generator/jump/placement/BlockGenerationOrchestrator` (generateSingleBlock, switch DEFAULT/SPECIAL/SCHEMATIC) · `generator/jump/placement/SchematicBlockGenerator` (tryGenerateSchematic) · `block/jump/TallMaterialResolver` (isTallMaterial + форм.высота) |
| `mode/impl/InvisibleBarrierMode` | **split+move** | `mode/impl/InvisibleBarrierMode` (keep, тонкий фасад) · `mode/impl/barrier/BarrierGenerator` (keep как есть, переезжает в подпакет) · `mode/impl/barrier/BarrierVisualState` (activeBarriers + blockColors + self-clean) · `block/render/BlockOutlineRenderer` (drawEdge/drawBlockOutline) · `block/render/ParticleBoxGeometry` (record Point + рёбра) |
| `hook/papi/PAPIHook` | **split** | `hook/papi/PAPIHook` (keep, тонкий: метаданные expansion + роутинг) · `hook/papi/PlaceholderRegistry` (плейсхолдер → resolver) · `hook/papi/PlayerPlaceholders` (score/time/lead/style/difficulty…) · `hook/papi/LeaderboardPlaceholders` (rank_*, leader/record) · `hook/papi/DifficultyFormatter` (parseDifficulty) · опционально `ScoreUntilPlaceholder` (score_until_N) · переименовать id `witp` → `loparkour` (п.118) |
| `storage/sql/StorageSQL` | **split** | `storage/sql/StorageSQL` (keep, фасад) · `storage/sql/SQLPool` (один Hikari-пул на плагин — фикс п.27, `setInitializationFailTimeout(-1)` п.28) · `storage/sql/SQLSchemaManager` (единый мигратор DDL — фикс п.62) · `storage/sql/SQLScoreRepository` (readScores/writeScores) · `storage/sql/SQLPlayerRepository` (readPlayer/writePlayer) · `storage/sql/SQLCallbackQueue` (runWhenConnected, фикс п.29) · `SQLQueryBuilder`, `SQLDataMapper`, `SQLMigrationManager` — keep |
| `player/core/ParkourPlayer` | **split** | `core/player/ParkourPlayer` (*keep* агрегат, тонкий) · `player/data/PlayerSettings` (Expose-поля настроек + их serialize/restore) · `player/service/ScoreTracker` (scoredBlocks/hasScored/markScored/blockKey) · `player/service/PlayerPersistence` (save/read) · `player/service/PlayerSetup` (setup/teleport/hotbar) · `player/service/GeneratorSettingsSync` (updateGeneratorSettings) · `player/core/ParkourPlayerRegistry` (статические getPlayer/getPlayers/isPlayer — заодно фикс O(n), п.47) |
| `leaderboard/core/Leaderboard` | **split** | `leaderboard/core/Leaderboard` (keep, фасад) · `leaderboard/core/ScoreComparator` (isBetterThan/тай-брейк) · `leaderboard/core/LeaderboardQueries` (getRank/getScoreAtRank/sort-снапшоты) · `LeaderboardStorage`, `LeaderboardSorter` — keep · **не split**: `model/Score.getTimeMillis` — bugfix парсинга `mm:ss.SSS`/`HH:mm:ss.SSS` и `long` (п.25) |
| `adaptive/core/MetricsCollector` | **split** | `adaptive/core/MetricsCollector` (keep: сценарии GeneratorEventListener) · `adaptive/core/JumpAnalyzer` (near-miss, время на блок) · `adaptive/core/MetricsAutoSave` (таймер автосейва, фикс Folia п.21) · `adaptive/core/PlayerMetricsCache` (кэш/выгрузка) · `adaptive/storage/StatsRepository` — keep |
| `mode/impl/CoopMode` | **split** (на границе 200) | `mode/impl/coop/CoopMode` (keep: MultiMode-фасад) · `mode/impl/coop/CoopSession` (создание лобби/инвайтов, создание сессии в create — фикс п.23) · `mode/impl/coop/CoopGenerator` (contributions/милстоны) · guard в unregister (фикс рекурсии п.24) |
## Новые режимы
Оба режима разрабатываются сразу в чистом виде (полное соответствие checkstyle, ноль PMD-нарушений) —
новая функциональность не может «перевозить» legacy-долг. Код режима НЕ добавляется в `suppressions.xml`.
### Duels — `duels/*` (в терминах задачи `mode/duels/*`)
Модель «генератор на игрока»: одна сессия-владелец, у каждого игрока свой генератор.
- **Пакеты:** `duels/mode/DuelsMode` (`implements MultiMode`), `duels/generator/DuelsGenerator`
(owner, `Map<ParkourPlayer, SingleDuelsGenerator>`, allowJoining, goal, spawnData),
`duels/generator/SingleDuelsGenerator` (`extends ParkourGenerator` со своим `state`/`history`/цветом/островом),
`duels/match/` (goal, счёт, initCountdown, win с guard `stopped`, идемпотентный синхронный `reset(false)`),
`duels/menu/MultiplayerMenu` + кнопка в `PlayMenu`.
- **Переиспользуют ядро:** `ParkourGenerator` (база), `BlockPlacer` + `block/*`, `PlayerInteractionHandler`
(скоринг по своей трассе), `ParkourHotbar` (баннер-старт), `PreviousData` (сейв/восстановление),
`Session`, `UserRegistry`. Результаты НЕ пишутся в лидерборд (`getLeaderboard() = null`).
- **Требования к ядру:** хук `selectBlockData()` (фиксированный цвет на игрока), пер-игроковая спавн-площадка
со сдвигом, ослабить `final class Island`, leave() без `ParkourUser.leave`-рекурсии (guard-флаг, п.24).
- **Не повторять баги Race/Coop:** без TIME-лидерборда (время брать своим форматтером из `state.start`),
ресет только синхронно с null-guard, сессия создаётся в `create()`, `join()` через `isAcceptingPlayers`.
### Elytra — `elytra/*` (в терминах задачи `mode/elytra/*`)
Один генератор на игрока, трасса = секции узлов → сплайн → «кольца»; **клиент-сайд рендер** без записи в мир.
- **Пакеты:** `elytra/section/ElytraSection`, `elytra/section/ElytraKnotDirector` (dx~N(75,15), dy-bias, dz~N(0,35)),
`elytra/section/ElytraPointType` (CIRCLE/FLAT), `elytra/generator/ElytraGenerator` (`extends ParkourGenerator`,
свой `tick()`, переопределённый `getMode()`, свой fall-детект — не полагаться на `LifecycleTickManager.checkPlayerFall`),
`elytra/mode/` (ElytraMode + Default/SpeedDemon/MinSpeed/TimeTrial/Close/Obstacle — по подклассу на режим),
`elytra/render/` (очередь `Map<chunkX, Set<Vector>>` → `player.sendBlockChanges(states)`, лимит min(viewDistance, 8)).
- **Переиспользуют ядро:** `ParkourGenerator` (база + профили сложности), `ParkourHotbar`, `PreviousData`,
`block/client/` (виртуальные блоки), зону `Divider`; скор — double (добавить float/double-вариант `Score` или
отдельный счётчик).
- **Требования к ядру:** пер-игроковый `state` (не разделяемый на сессию), высота/порог ASCEND под наши
zone-границы, раздача элитр в слот нагрудника через `ParkourHotbar`.
- **Зависимости:** Apache Commons Math (`SplineInterpolator`) или Catmull-Rom — без стороннего мира.
Тесты новых режимов (п.131): сплайн/секции, `ElytraGenerator.tick` (score по velocity.x, причины reset),
`DuelsGenerator.win` (guard/идемпотентность), `SingleDuelsGenerator.score→win` при goal, регрессия рекурсии unregister.
## Политика линтеров
- **Checkstyle — жёсткий гейт** (`isIgnoreFailures=false`, `maxErrors=0` в `build.gradle.kts`): правило `FileLength`
(200) и прочие жёсткие проверки падают сборку. Сейчас сборка проходит `0 errors`, но копит **~1429 warning**.
- **Шумные правила — severity=`warning`, не блокируют** (задано в `checkstyle.xml`): `MagicNumber`,
`FinalLocalVariable`, `EmptyLineSeparator`, `ClassDataAbstractionCoupling`, `ClassFanOutComplexity`,
`MissingDeprecated`. Они не требуют супрессий и чистятся отдельным проходом.
- **Legacy-нарушения — точечные супрессии** в `config/checkstyle/suppressions.xml` (`FileLength`,
`IllegalCatch`, `NPathComplexity`, `WhitespaceAround`). Каждая запись привязана к конкретному файлу,
удаляется сразу после декомпозиции/зачистки этого файла. **Новые файлы в супрессии не добавляются.**
- **PMD — report-only** (`isIgnoreFailures=true`), сейчас **45 нарушений**:
`CognitiveComplexity(17)` + `CyclomaticComplexity(7)` + `GodClass(4)` + `TooManyMethods(8)` — декомпозиция;
`AvoidReassigningParameters(7)` — переименование параметров; `EmptyCatchBlock(2)` — комментарий/тип.
После зачистки (п.137) `isIgnoreFailures` убирается, PMD становится гейтом (п.138).
- В `TODO.md` «Зачистка варнингов линтеров»: `FinalLocalVariable` ~1034 (чистить вместе с декомпозицией),
`MagicNumber` ~346, `WhitespaceAround` 30, `NPathComplexity` 29, `IllegalCatch` 22.
## Порядок работ
1. **Архитектура** — утвердить этот документ, зафиксировать целевое дерево пакетов и карту миграции.
2. **Декомпозиция god-классов** — по карте миграции (BlockPlacer → `block/*`, LoParkourCommand → роутеры,
PAPIHook → реестры плейсхолдеров, StorageSQL → репозитории + один пул, ParkourPlayer → сервисы,
InvisibleBarrierMode → `block/render`, ConfigUpdater → мержер и т.д.). После каждого файла — удаление его
супрессии из `suppressions.xml` (сборка обязана оставаться зелёной).
3. **Зачистка варнингов** — `FinalLocalVariable`/`MagicNumber`/`WhitespaceAround`/`NPath`/`IllegalCatch`
по отчётам `build/reports/checkstyle/`; закрыть оставшиеся PMD-нарушения.
4. **Elytra** — база + режимы + клиент-сайд рендер (меньше зависимостей, чем Duels; S/M-сложность).
5. **Duels** — `duels/*` с предварительными хуками ядра (`selectBlockData`, пер-игроковый state,
`final Island`); требует предварительно закрытого Coop-рекурсивного цикла (п.24).
6. **PMD-гейт** — убрать `isIgnoreFailures=true`, PMD в CI как жёсткий гейт; удалить пустые записи из
`suppressions.xml`.

View file

@ -18,6 +18,7 @@ import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.util.ArrayList;
import java.util.logging.Level;
/**
* Main class of LoParkour
@ -112,8 +113,7 @@ public final class LoParkour extends LoPlugin {
Storage.close();
World.delete();
} catch (Throwable t) {
getLogger().severe("Error during plugin shutdown: " + t.getMessage());
t.printStackTrace();
getLogger().log(Level.SEVERE, "Error during plugin shutdown: " + t.getMessage(), t);
}
}
}

View file

@ -0,0 +1,48 @@
package dev.loki.loparkour.adaptive.core;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Aggregates timing and position between parkour landings.
*
* Responsibilities:
* - Track the last landed block per player
* - Track the timestamp of the last landing per player
* - Provide the timing window between consecutive landings
*
* Thread-safe: Uses ConcurrentHashMap for player caches.
*/
class JumpTimingTracker {
private final Map<UUID, Block> lastBlockCache;
private final Map<UUID, Long> lastLandTimeCache;
JumpTimingTracker() {
this.lastBlockCache = new ConcurrentHashMap<>();
this.lastLandTimeCache = new ConcurrentHashMap<>();
}
@Nullable
Long lastLandTime(@NotNull UUID playerUuid) {
return lastLandTimeCache.get(playerUuid);
}
void markLand(@NotNull UUID playerUuid, long timeMillis) {
lastLandTimeCache.put(playerUuid, timeMillis);
}
void updateLastBlock(@NotNull UUID playerUuid, @NotNull Block block) {
lastBlockCache.put(playerUuid, block);
}
void unload(@NotNull UUID playerUuid) {
lastBlockCache.remove(playerUuid);
lastLandTimeCache.remove(playerUuid);
}
}

View file

@ -0,0 +1,74 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.generator.jump.calculation.JumpType;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Collects raw generator events into player metrics.
*
* Responsibilities:
* - Record successful block landings with timing
* - Record jump events with type and timing
* - Detect near-miss jumps (distance > 3.5 blocks)
* - Persist metrics when a player falls
*/
class MetricEventCollector {
private static final double NEAR_MISS_THRESHOLD = 3.5;
private final MetricsCacheStore cacheStore;
private final JumpTimingTracker timingTracker;
MetricEventCollector(@NotNull MetricsCacheStore cacheStore, @NotNull JumpTimingTracker timingTracker) {
this.cacheStore = cacheStore;
this.timingTracker = timingTracker;
}
void onBlockLand(@NotNull Player player, @NotNull Block block, double distance) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = cacheStore.getOrCreate(uuid);
long currentTime = System.currentTimeMillis();
Long lastTime = timingTracker.lastLandTime(uuid);
if (lastTime != null) {
metrics.recordJump("normal", currentTime - lastTime);
}
timingTracker.markLand(uuid, currentTime);
if (distance > NEAR_MISS_THRESHOLD) {
metrics.recordNearMiss();
}
timingTracker.updateLastBlock(uuid, block);
}
void onPlayerScore(@NotNull Player player, @NotNull Block block, double distance) {
onBlockLand(player, block, distance);
}
void onPlayerFall(@NotNull Player player) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = cacheStore.getOrCreate(uuid);
cacheStore.save(metrics);
}
void onJump(@NotNull Player player, @NotNull JumpType jumpType) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = cacheStore.getOrCreate(uuid);
long currentTime = System.currentTimeMillis();
Long lastTime = timingTracker.lastLandTime(uuid);
long timeDiff = (lastTime != null) ? (currentTime - lastTime) : 0;
metrics.recordJump(jumpType.name().toLowerCase(), timeDiff);
}
void unload(@NotNull UUID playerUuid) {
timingTracker.unload(playerUuid);
}
}

View file

@ -0,0 +1,100 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.adaptive.storage.StatsRepository;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.NotNull;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Stores and prunes the cached player metrics.
*
* Responsibilities:
* - Load or create metrics per player
* - Persist metrics to the repository
* - Remove players on unload
* - Schedule periodic auto-save
*
* Thread-safe: Uses ConcurrentHashMap for player metrics cache.
*/
class MetricsCacheStore {
private static final long AUTO_SAVE_INTERVAL_TICKS = 20 * 60 * 5; // 5 minutes
private final Plugin plugin;
private final StatsRepository repository;
private final Map<UUID, PlayerMetrics> metricsCache;
private BukkitRunnable autoSaveTask;
MetricsCacheStore(@NotNull Plugin plugin, @NotNull StatsRepository repository) {
this.plugin = plugin;
this.repository = repository;
this.metricsCache = new ConcurrentHashMap<>();
}
@NotNull
PlayerMetrics getOrCreate(@NotNull UUID playerUuid) {
return metricsCache.computeIfAbsent(playerUuid, this::loadOrCreate);
}
private @NotNull PlayerMetrics loadOrCreate(@NotNull UUID playerUuid) {
PlayerMetrics loaded = repository.loadMetrics(playerUuid);
if (loaded != null) {
return loaded;
}
return new PlayerMetrics(playerUuid);
}
void save(@NotNull UUID playerUuid) {
PlayerMetrics metrics = metricsCache.get(playerUuid);
if (metrics != null) {
repository.saveMetrics(metrics);
}
}
void save(@NotNull PlayerMetrics metrics) {
repository.saveMetrics(metrics);
}
void saveAll() {
for (PlayerMetrics metrics : metricsCache.values()) {
repository.saveMetrics(metrics);
}
}
void unload(@NotNull UUID playerUuid) {
save(playerUuid);
metricsCache.remove(playerUuid);
}
int size() {
return metricsCache.size();
}
void startAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
}
autoSaveTask = new BukkitRunnable() {
@Override
public void run() {
saveAll();
}
};
autoSaveTask.runTaskTimerAsynchronously(plugin, AUTO_SAVE_INTERVAL_TICKS, AUTO_SAVE_INTERVAL_TICKS);
}
void stopAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
autoSaveTask = null;
}
}
}

View file

@ -7,13 +7,10 @@ import dev.loki.loparkour.generator.jump.calculation.JumpType;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitRunnable;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Collects and manages player performance metrics.
@ -26,19 +23,16 @@ import java.util.concurrent.ConcurrentHashMap;
* - Auto-save metrics every 5 minutes
*
* Thread-safe: Uses ConcurrentHashMap for player metrics cache.
*
* Facade over the metrics subsystem:
* - {@link MetricsCacheStore} handles cache storage, pruning, and persistence
* - {@link MetricEventCollector} handles raw generator events
* - {@link JumpTimingTracker} aggregates timing windows between landings
*/
public class MetricsCollector implements GeneratorEventListener {
private static final double NEAR_MISS_THRESHOLD = 3.5;
private static final long AUTO_SAVE_INTERVAL_TICKS = 20 * 60 * 5; // 5 minutes
private final Plugin plugin;
private final StatsRepository repository;
private final Map<UUID, PlayerMetrics> metricsCache;
private final Map<UUID, Block> lastBlockCache;
private final Map<UUID, Long> lastBlockTimeCache;
private BukkitRunnable autoSaveTask;
private final MetricsCacheStore cacheStore;
private final MetricEventCollector eventCollector;
/**
* Create a new MetricsCollector.
@ -47,11 +41,8 @@ public class MetricsCollector implements GeneratorEventListener {
* @param repository The repository for persisting metrics
*/
public MetricsCollector(@NotNull Plugin plugin, @NotNull StatsRepository repository) {
this.plugin = plugin;
this.repository = repository;
this.metricsCache = new ConcurrentHashMap<>();
this.lastBlockCache = new ConcurrentHashMap<>();
this.lastBlockTimeCache = new ConcurrentHashMap<>();
this.cacheStore = new MetricsCacheStore(plugin, repository);
this.eventCollector = new MetricEventCollector(cacheStore, new JumpTimingTracker());
}
/**
@ -59,18 +50,7 @@ public class MetricsCollector implements GeneratorEventListener {
* Should be called when the collector is initialized.
*/
public void startAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
}
autoSaveTask = new BukkitRunnable() {
@Override
public void run() {
saveAllMetrics();
}
};
autoSaveTask.runTaskTimerAsynchronously(plugin, AUTO_SAVE_INTERVAL_TICKS, AUTO_SAVE_INTERVAL_TICKS);
cacheStore.startAutoSave();
}
/**
@ -78,10 +58,7 @@ public class MetricsCollector implements GeneratorEventListener {
* Should be called when the plugin is disabled.
*/
public void stopAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
autoSaveTask = null;
}
cacheStore.stopAutoSave();
}
/**
@ -92,15 +69,7 @@ public class MetricsCollector implements GeneratorEventListener {
*/
@NotNull
public PlayerMetrics getMetrics(@NotNull UUID playerUuid) {
return metricsCache.computeIfAbsent(playerUuid, uuid -> {
// Try to load from repository
PlayerMetrics loaded = repository.loadMetrics(uuid);
if (loaded != null) {
return loaded;
}
// Create new metrics if not found
return new PlayerMetrics(uuid);
});
return cacheStore.getOrCreate(playerUuid);
}
/**
@ -112,26 +81,7 @@ public class MetricsCollector implements GeneratorEventListener {
* @param distance The distance of the jump
*/
public void onBlockLand(@NotNull Player player, @NotNull Block block, double distance) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = getMetrics(uuid);
// Calculate time since last block
long currentTime = System.currentTimeMillis();
Long lastTime = lastBlockTimeCache.get(uuid);
if (lastTime != null) {
long timeDiff = currentTime - lastTime;
// Record jump with timing (use "normal" as default type)
metrics.recordJump("normal", timeDiff);
}
lastBlockTimeCache.put(uuid, currentTime);
// Detect near-miss (long jump that succeeded)
if (distance > NEAR_MISS_THRESHOLD) {
metrics.recordNearMiss();
}
// Update last block
lastBlockCache.put(uuid, block);
eventCollector.onBlockLand(player, block, distance);
}
@Override
@ -141,30 +91,17 @@ public class MetricsCollector implements GeneratorEventListener {
@Override
public void onPlayerScore(@NotNull Player player, @NotNull Block block, double distance) {
onBlockLand(player, block, distance);
eventCollector.onPlayerScore(player, block, distance);
}
@Override
public void onPlayerFall(@NotNull Player player, @Nullable Block lastBlock) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = getMetrics(uuid);
// Save metrics on fall (important event)
repository.saveMetrics(metrics);
eventCollector.onPlayerFall(player);
}
@Override
public void onJump(@NotNull Player player, @NotNull JumpType jumpType) {
UUID uuid = player.getUniqueId();
PlayerMetrics metrics = getMetrics(uuid);
// Calculate time since last block
long currentTime = System.currentTimeMillis();
Long lastTime = lastBlockTimeCache.get(uuid);
long timeDiff = (lastTime != null) ? (currentTime - lastTime) : 0;
// Record jump with type and timing
metrics.recordJump(jumpType.name().toLowerCase(), timeDiff);
eventCollector.onJump(player, jumpType);
}
/**
@ -173,10 +110,7 @@ public class MetricsCollector implements GeneratorEventListener {
* @param playerUuid The player's UUID
*/
public void saveMetrics(@NotNull UUID playerUuid) {
PlayerMetrics metrics = metricsCache.get(playerUuid);
if (metrics != null) {
repository.saveMetrics(metrics);
}
cacheStore.save(playerUuid);
}
/**
@ -184,9 +118,7 @@ public class MetricsCollector implements GeneratorEventListener {
* Called by auto-save task and on plugin disable.
*/
public void saveAllMetrics() {
for (PlayerMetrics metrics : metricsCache.values()) {
repository.saveMetrics(metrics);
}
cacheStore.saveAll();
}
/**
@ -196,11 +128,8 @@ public class MetricsCollector implements GeneratorEventListener {
* @param playerUuid The player's UUID
*/
public void unloadPlayer(@NotNull UUID playerUuid) {
// Save before removing
saveMetrics(playerUuid);
metricsCache.remove(playerUuid);
lastBlockCache.remove(playerUuid);
lastBlockTimeCache.remove(playerUuid);
cacheStore.unload(playerUuid);
eventCollector.unload(playerUuid);
}
/**
@ -209,6 +138,6 @@ public class MetricsCollector implements GeneratorEventListener {
* @return The cache size
*/
public int getCacheSize() {
return metricsCache.size();
return cacheStore.size();
}
}

View file

@ -40,13 +40,13 @@ public class SQLStatsStorage implements StatsRepository {
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, playerUuid.toString());
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
double rating = rs.getDouble("skill_rating");
int sessions = rs.getInt("sessions_count");
double confidence = Math.min(1.0, sessions * 0.05);
return new SkillRating(playerUuid, rating, confidence, sessions);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
double rating = rs.getDouble("skill_rating");
int sessions = rs.getInt("sessions_count");
double confidence = Math.min(1.0, sessions * 0.05);
return new SkillRating(playerUuid, rating, confidence, sessions);
}
}
} catch (SQLException e) {
LoParkour.getPlugin().getLogger().warning("Failed to load skill rating: " + e.getMessage());

View file

@ -3,7 +3,7 @@ package dev.loki.loparkour.bootstrap;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.LoParkourCommand;
import dev.loki.loparkour.command.core.LoParkourCommand;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
@ -13,13 +13,13 @@ import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener;
import dev.loki.loparkour.listener.schematic.SchematicWandListener;
import dev.loki.loparkour.listener.player.PlayerConnectionListener;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.mode.impl.CoopMode;
import dev.loki.loparkour.mode.impl.DefaultMode;
import dev.loki.loparkour.mode.impl.GravityShiftMode;
import dev.loki.loparkour.mode.impl.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.RaceMode;
import dev.loki.loparkour.mode.impl.SpectatorMode;
import dev.loki.loparkour.mode.impl.SpeedrunMode;
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.multi.CoopMode;
import dev.loki.loparkour.mode.impl.multi.RaceMode;
import dev.loki.loparkour.mode.impl.multi.SpectatorMode;
import dev.loki.loparkour.mode.impl.solo.DefaultMode;
import dev.loki.loparkour.mode.impl.solo.GravityShiftMode;
import dev.loki.loparkour.mode.impl.solo.SpeedrunMode;
import dev.loki.loparkour.reward.core.Rewards;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.world.core.World;

View file

@ -1,302 +0,0 @@
package dev.loki.loparkour.command;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.admin.AdminCommandHandler;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
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}.
*/
public class LoParkourCommand implements CommandExecutor, TabCompleter {
private static final List<String> SCHEMATIC_DIFFICULTIES = List.of("0.0", "0.25", "0.5", "0.75", "1.0");
private static final List<String> SCHEMATIC_SUBCOMMANDS = List.of(
"wand", "pos1", "pos2", "create", "convert", "paste", "list", "reload"
);
private final PlayerCommandHandler player = new PlayerCommandHandler();
private final AdminCommandHandler admin = new AdminCommandHandler(player);
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
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);
case 4 -> handle4(args[0], args[1], args[2], args[3], sender, p);
default -> Locales.send(sender, "commands.too_many_args");
}
return true;
} catch (Throwable t) {
LoParkour.getPlugin().getLogger().log(Level.SEVERE,
"LoParkour command failed (" + label + " " + String.join(" ", args) + ")", t);
Locales.send(sender, "commands.error");
return true;
}
}
// ── 2-arg routing ──────────────────────────────────────────────────────────
private void handle2(String a1, String a2, CommandSender sender, @Nullable Player p) {
// Admin commands that don't need a player
switch (a1.toLowerCase()) {
case "forcejoin", "forceleave", "reset", "recoverinventory" -> {
admin.handle(a1, a2, sender, p);
return;
}
default -> {
}
}
if (p == null) {
return;
}
if ("create".equalsIgnoreCase(a1) && p.hasPermission(ParkourOption.ADMIN.permission)) {
SchematicCommandHandler.handleCreateDifficultyOnly(a2, sender, p, player);
return;
}
switch (a1.toLowerCase()) {
case "join" -> handleJoin(a2, sender, p);
case "leaderboard" -> handleLeaderboard(a2, sender, p);
case "schematic" -> {
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
send(sender, Locales.getString(p, "other.no_do"));
return;
}
SchematicCommandHandler.handleSubcommand(a2, sender, p, player);
}
default -> {
}
}
}
// ── 3-arg routing ──────────────────────────────────────────────────────────
private void handle3(String a1, String a2, String a3, CommandSender sender, @Nullable Player p) {
if (p == null) {
return;
}
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
return;
}
if ("schematic".equalsIgnoreCase(a1)) {
if ("create".equalsIgnoreCase(a2)) {
SchematicCommandHandler.handleCreateDifficultyOnly(a3, sender, p, player);
return;
}
SchematicCommandHandler.handleSubcommandWithName(a2, a3, sender, p);
}
}
private void handle4(String a1, String a2, String a3, String a4, CommandSender sender, @Nullable Player p) {
if (p == null) {
return;
}
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
return;
}
if (!"schematic".equalsIgnoreCase(a1) || !"create".equalsIgnoreCase(a2)) {
return;
}
SchematicCommandHandler.handleCreateWithDifficulty(a3, a4, sender, p, player);
}
// ── join logic ─────────────────────────────────────────────────────────────
private void handleJoin(String arg, CommandSender sender, Player p) {
if (!player.cooldown(sender, "join", 2500) || !ParkourOption.JOIN.mayPerform(p)) {
return;
}
Mode mode = Registry.getMode(arg);
if (mode != null) {
mode.create(p);
return;
}
Player other = Bukkit.getPlayer(arg);
if (other == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.unknown_player"));
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(other);
if (pp == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.not_playing"));
return;
}
ParkourUser user = ParkourUser.getUser(p);
Session session = pp.session;
if (user != null && user.session == session) {
return;
}
if (session.isAcceptingPlayers()) {
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);
}
}
// ── leaderboard logic ──────────────────────────────────────────────────────
private void handleLeaderboard(String arg, CommandSender sender, Player p) {
if (!ParkourOption.LEADERBOARDS.mayPerform(p)) {
send(sender, Locales.getString(p, "other.no_do"));
return;
}
Mode mode = Registry.getMode(arg.toLowerCase());
if (mode == null) {
Menus.LEADERBOARDS.open(p);
} else {
Menus.SINGLE_LEADERBOARD.open(p, mode, Leaderboard.Sort.SCORE);
}
}
// ── tab complete ───────────────────────────────────────────────────────────
@Override
@SuppressWarnings("checkstyle:CyclomaticComplexity")
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
List<String> completions = new ArrayList<>();
if (args.length == 1) {
if (ParkourOption.JOIN.mayPerform(sender)) {
completions.add("join");
completions.add("leave");
}
if (ParkourOption.MAIN.mayPerform(sender)) {
completions.add("menu");
}
if (ParkourOption.PLAY.mayPerform(sender)) {
completions.add("play");
}
if (ParkourOption.LEADERBOARDS.mayPerform(sender)) {
completions.add("leaderboard");
}
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(List.of("schematic", "create", "reload", "forcejoin", "forceleave", "reset", "recoverinventory"));
}
return filter(args[0], completions);
}
if (args.length == 2) {
String a1 = args[0].toLowerCase();
if ("reset".equals(a1) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
} else if ("join".equals(a1) && ParkourOption.JOIN.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
} else if ("leaderboard".equals(a1) && ParkourOption.LEADERBOARDS.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
} else if ("schematic".equals(a1) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_SUBCOMMANDS);
} else if ("create".equals(a1) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
} else if (("forcejoin".equals(a1) || "forceleave".equals(a1)) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
if ("forcejoin".equals(a1)) {
completions.add("nearest");
}
Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName()));
} else if ("recoverinventory".equals(a1) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName()));
}
return filter(args[1], completions);
}
if (args.length == 3 && sender.hasPermission(ParkourOption.ADMIN.permission)) {
String a1 = args[0].toLowerCase();
String a2 = args[1].toLowerCase();
if ("schematic".equals(a1)) {
if ("paste".equals(a2)) {
addLoadedSchematicIds(completions);
} else if ("create".equals(a2)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
}
return filter(args[2], completions);
}
if (args.length == 4 && sender.hasPermission(ParkourOption.ADMIN.permission)) {
String a1 = args[0].toLowerCase();
String a2 = args[1].toLowerCase();
if ("schematic".equals(a1) && "create".equals(a2)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
return filter(args[3], completions);
}
return Collections.emptyList();
}
private static void addLoadedSchematicIds(@NotNull List<String> completions) {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return;
}
for (String id : manager.getAll().keySet()) {
completions.add(id);
String shortId = SchematicManager.configKey(id);
if (!shortId.equalsIgnoreCase(id)) {
completions.add(shortId);
}
}
}
// ── util ───────────────────────────────────────────────────────────────────
private List<String> filter(String input, List<String> options) {
String lower = input.toLowerCase();
return options.stream().filter(s -> s.toLowerCase().startsWith(lower)).toList();
}
private void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -1,28 +1,13 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.player.data.InventoryData;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* Handles admin-only /LoParkour sub-commands:
* forcejoin, forceleave, reset, recoverinventory.
@ -30,9 +15,17 @@ import java.util.UUID;
public class AdminCommandHandler {
private final PlayerCommandHandler base;
private final ForceJoinExecutor forceJoin;
private final ForceLeaveExecutor forceLeave;
private final ResetExecutor reset;
private final RecoverInventoryExecutor recoverInventory;
public AdminCommandHandler(PlayerCommandHandler base) {
this.base = base;
this.forceJoin = new ForceJoinExecutor(base);
this.forceLeave = new ForceLeaveExecutor(base);
this.reset = new ResetExecutor(base);
this.recoverInventory = new RecoverInventoryExecutor(base);
}
public void handle(@NotNull String arg1, @NotNull String arg2,
@ -42,173 +35,37 @@ public class AdminCommandHandler {
return;
}
dispatchAdmin(arg1, arg2, sender);
dispatchSchematic(arg1, arg2, sender, player);
}
private void dispatchAdmin(@NotNull String arg1, @NotNull String arg2, @NotNull CommandSender sender) {
switch (arg1.toLowerCase()) {
case "forcejoin" -> handleForceJoin(arg2, sender);
case "forceleave" -> handleForceLeave(arg2, sender);
case "reset" -> handleReset(arg2, sender);
case "recoverinventory" -> handleRecoverInventory(arg2, sender);
case "forcejoin" -> {
forceJoin.execute(arg2, sender);
}
case "forceleave" -> {
forceLeave.execute(arg2, sender);
}
case "reset" -> {
reset.execute(arg2, sender);
}
case "recoverinventory" -> {
recoverInventory.execute(arg2, sender);
}
default -> {
/* unknown admin subcommand: handle() falls through to schematic */
}
}
}
private void dispatchSchematic(@NotNull String arg1, @NotNull String arg2,
@NotNull CommandSender sender, @Nullable Player player) {
if (player == null) {
return;
}
if ("schematic".equals(arg1)) {
SchematicCommandHandler.handleSubcommand(arg2, sender, player, base);
}
}
// ── forcejoin ──────────────────────────────────────────────────────────────
private void handleForceJoin(String target, CommandSender sender) {
if (!base.cooldown(sender, "forcejoin", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_everyone"));
return;
}
if ("nearest".equalsIgnoreCase(target)) {
Player closest = findNearest(sender);
if (closest == null) {
return;
}
Modes.DEFAULT.create(closest);
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_player").formatted(closest.getName()));
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
Modes.DEFAULT.create(other);
}
// ── forceleave ─────────────────────────────────────────────────────────────
private void handleForceLeave(String target, CommandSender sender) {
if (!base.cooldown(sender, "forceleave", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_leave_everyone"));
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
ParkourUser user = ParkourUser.getUser(other);
if (user == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_playing"));
return;
}
ParkourUser.leave(user);
}
// ── reset ──────────────────────────────────────────────────────────────────
private void handleReset(String target, CommandSender sender) {
if (!base.cooldown(sender, "reset", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
Registry.getModes().stream()
.map(Mode::getLeaderboard)
.filter(lb -> lb != null)
.forEach(lb -> {
lb.resetAll();
lb.write(true);
});
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_all"));
return;
}
UUID uuid = resolveUUID(target);
String name = resolvePlayerName(target, uuid);
Registry.getModes().stream()
.map(Mode::getLeaderboard)
.filter(lb -> lb != null)
.forEach(lb -> {
lb.remove(uuid);
lb.write(true);
});
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_player").formatted(name));
}
// ── recoverinventory ───────────────────────────────────────────────────────
private void handleRecoverInventory(String target, CommandSender sender) {
if (!base.cooldown(sender, "recoverinventory", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
new InventoryData(other).load(result -> {
if (result != null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.recover_inventory").formatted(other.getName()));
} else {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.recover_inventory_missing").formatted(other.getName()));
}
});
}
// ── helpers ────────────────────────────────────────────────────────────────
private @Nullable Player findNearest(CommandSender sender) {
Location from = sender instanceof Player p ? p.getLocation()
: sender instanceof BlockCommandSender b ? b.getBlock().getLocation()
: null;
if (from == null || from.getWorld() == null) {
return null;
}
return from.getWorld().getPlayers().stream()
.min((a, b) -> Double.compare(a.getLocation().distance(from), b.getLocation().distance(from)))
.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("-")) {
try {
return UUID.fromString(input);
} catch (IllegalArgumentException ignored) {
// fall through to offline lookup by name
}
}
return Bukkit.getOfflinePlayer(input).getUniqueId();
}
private String resolvePlayerName(String input, UUID uuid) {
Player online = Bukkit.getPlayerExact(input);
if (online != null) {
return online.getName();
}
OfflinePlayer op = Bukkit.getOfflinePlayer(uuid);
return op.getName() != null ? op.getName() : input;
}
private void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -0,0 +1,14 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.command.CommandSender;
final class AdminResponses {
private AdminResponses() {
}
static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -0,0 +1,61 @@
package dev.loki.loparkour.command.admin;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.UUID;
final class AdminTargetResolver {
private AdminTargetResolver() {
}
static Player findNearest(CommandSender sender) {
Location from = sender instanceof Player p ? p.getLocation()
: sender instanceof BlockCommandSender b ? b.getBlock().getLocation()
: null;
if (from == null || from.getWorld() == null) {
return null;
}
return from.getWorld().getPlayers().stream()
.min((a, b) -> Double.compare(a.getLocation().distance(from), b.getLocation().distance(from)))
.orElse(null);
}
@SuppressWarnings("deprecation")
static UUID resolveUUID(String input) {
Player online = Bukkit.getPlayerExact(input);
if (online != null) {
return online.getUniqueId();
}
if (input.contains("-")) {
UUID parsed = tryParseUuid(input);
if (parsed != null) {
return parsed;
}
}
return Bukkit.getOfflinePlayer(input).getUniqueId();
}
private static UUID tryParseUuid(String input) {
try {
return UUID.fromString(input);
} catch (IllegalArgumentException ignored) {
return null;
}
}
static String resolvePlayerName(String input, UUID uuid) {
Player online = Bukkit.getPlayerExact(input);
if (online != null) {
return online.getName();
}
OfflinePlayer op = Bukkit.getOfflinePlayer(uuid);
return op.getName() != null ? op.getName() : input;
}
}

View file

@ -0,0 +1,56 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.mode.base.Modes;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class ForceJoinExecutor {
private final PlayerCommandHandler base;
ForceJoinExecutor(PlayerCommandHandler base) {
this.base = base;
}
void execute(String target, CommandSender sender) {
if (!base.cooldown(sender, "forcejoin", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
joinEveryone(sender);
return;
}
if ("nearest".equalsIgnoreCase(target)) {
joinNearest(sender);
return;
}
joinByName(target, sender);
}
private void joinEveryone(CommandSender sender) {
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_everyone"));
}
private void joinNearest(CommandSender sender) {
Player closest = AdminTargetResolver.findNearest(sender);
if (closest == null) {
return;
}
Modes.DEFAULT.create(closest);
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_player").formatted(closest.getName()));
}
private void joinByName(String target, CommandSender sender) {
Player other = Bukkit.getPlayer(target);
if (other == null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
Modes.DEFAULT.create(other);
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class ForceLeaveExecutor {
private final PlayerCommandHandler base;
ForceLeaveExecutor(PlayerCommandHandler base) {
this.base = base;
}
void execute(String target, CommandSender sender) {
if (!base.cooldown(sender, "forceleave", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
leaveEveryone(sender);
return;
}
leaveByName(target, sender);
}
private void leaveEveryone(CommandSender sender) {
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_leave_everyone"));
}
private void leaveByName(String target, CommandSender sender) {
Player other = Bukkit.getPlayer(target);
if (other == null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
ParkourUser user = ParkourUser.getUser(other);
if (user == null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_playing"));
return;
}
ParkourUser.leave(user);
}
}

View file

@ -0,0 +1,41 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.player.data.InventoryData;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class RecoverInventoryExecutor {
private final PlayerCommandHandler base;
RecoverInventoryExecutor(PlayerCommandHandler base) {
this.base = base;
}
void execute(String target, CommandSender sender) {
if (!base.cooldown(sender, "recoverinventory", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online"));
return;
}
recover(other, sender);
}
private void recover(Player other, CommandSender sender) {
new InventoryData(other).load(result -> {
if (result != null) {
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.recover_inventory").formatted(other.getName()));
} else {
AdminResponses.send(sender, LoParkour.PREFIX
+ Locales.getString(sender, "admin.recover_inventory_missing").formatted(other.getName()));
}
});
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.mode.base.Mode;
import org.bukkit.command.CommandSender;
import java.util.UUID;
import java.util.function.Consumer;
final class ResetExecutor {
private final PlayerCommandHandler base;
ResetExecutor(PlayerCommandHandler base) {
this.base = base;
}
void execute(String target, CommandSender sender) {
if (!base.cooldown(sender, "reset", 2500, Locales.getString(sender, "admin.cooldown"))) {
return;
}
if ("everyone".equalsIgnoreCase(target)) {
resetEveryone(sender);
return;
}
resetPlayer(target, sender);
}
private void resetEveryone(CommandSender sender) {
forEachLeaderboard(lb -> {
lb.resetAll();
lb.write(true);
});
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_all"));
}
private void resetPlayer(String target, CommandSender sender) {
UUID uuid = AdminTargetResolver.resolveUUID(target);
String name = AdminTargetResolver.resolvePlayerName(target, uuid);
forEachLeaderboard(lb -> {
lb.remove(uuid);
lb.write(true);
});
AdminResponses.send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_player").formatted(name));
}
private static void forEachLeaderboard(Consumer<Leaderboard> action) {
Registry.getModes().stream()
.map(Mode::getLeaderboard)
.filter(lb -> lb != null)
.forEach(action);
}
}

View file

@ -0,0 +1,111 @@
package dev.loki.loparkour.command.core;
import dev.loki.loparkour.command.admin.AdminCommandHandler;
import dev.loki.loparkour.command.player.JoinCommandExecutor;
import dev.loki.loparkour.command.player.LeaderboardCommandExecutor;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.command.util.CommandUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.ParkourOption;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
class CommandRouter {
private final PlayerCommandHandler player;
private final AdminCommandHandler admin;
private final JoinCommandExecutor join;
private final LeaderboardCommandExecutor leaderboard;
CommandRouter(PlayerCommandHandler player, AdminCommandHandler admin) {
this.player = player;
this.admin = admin;
this.join = new JoinCommandExecutor();
this.leaderboard = new LeaderboardCommandExecutor();
}
void dispatch(String[] args, CommandSender sender, @Nullable Player 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);
case 4 -> handle4(args[0], args[1], args[2], args[3], sender, p);
default -> Locales.send(sender, "commands.too_many_args");
}
}
private void handle2(String a1, String a2, CommandSender sender, @Nullable Player p) {
if (isAdminCommand(a1)) {
admin.handle(a1, a2, sender, p);
return;
}
if (p == null) {
return;
}
if (canCreateSchematic(a1, p)) {
SchematicCommandHandler.handleCreateDifficultyOnly(a2, sender, p, player);
return;
}
handlePlayerSubcommand(a1, a2, sender, p);
}
private static boolean isAdminCommand(String a1) {
return switch (a1.toLowerCase()) {
case "forcejoin", "forceleave", "reset", "recoverinventory" -> true;
default -> false;
};
}
private static boolean canCreateSchematic(String a1, Player p) {
return "create".equalsIgnoreCase(a1) && p.hasPermission(ParkourOption.ADMIN.permission);
}
private void handlePlayerSubcommand(String a1, String a2, CommandSender sender, Player p) {
switch (a1.toLowerCase()) {
case "join" -> join.join(a2, sender, p, player);
case "leaderboard" -> leaderboard.open(a2, sender, p);
case "schematic" -> {
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
CommandUtil.send(sender, Locales.getString(p, "other.no_do"));
return;
}
SchematicCommandHandler.handleSubcommand(a2, sender, p, player);
}
default -> {
}
}
}
private void handle3(String a1, String a2, String a3, CommandSender sender, @Nullable Player p) {
if (p == null) {
return;
}
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
return;
}
if ("schematic".equalsIgnoreCase(a1)) {
if ("create".equalsIgnoreCase(a2)) {
SchematicCommandHandler.handleCreateDifficultyOnly(a3, sender, p, player);
return;
}
SchematicCommandHandler.handleSubcommandWithName(a2, a3, sender, p);
}
}
private void handle4(String a1, String a2, String a3, String a4, CommandSender sender, @Nullable Player p) {
if (p == null) {
return;
}
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
return;
}
if (!"schematic".equalsIgnoreCase(a1) || !"create".equalsIgnoreCase(a2)) {
return;
}
SchematicCommandHandler.handleCreateWithDifficulty(a3, a4, sender, p, player);
}
}

View file

@ -0,0 +1,181 @@
package dev.loki.loparkour.command.core;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.schematic.core.SchematicManager;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class CommandTabCompleter {
private static final List<String> SCHEMATIC_DIFFICULTIES = List.of("0.0", "0.25", "0.5", "0.75", "1.0");
private static final List<String> SCHEMATIC_SUBCOMMANDS = List.of(
"wand", "pos1", "pos2", "create", "convert", "paste", "list", "reload"
);
private CommandTabCompleter() {
}
static List<String> complete(@NotNull CommandSender sender, @NotNull String[] args) {
if (args.length == 1) {
return completeFirst(sender, args[0]);
}
if (args.length == 2) {
return completeSecond(sender, args[0], args[1]);
}
if (args.length == 3 && sender.hasPermission(ParkourOption.ADMIN.permission)) {
return completeThird(args[0], args[1], args[2]);
}
if (args.length == 4 && sender.hasPermission(ParkourOption.ADMIN.permission)) {
return completeFourth(args[0], args[1], args[3]);
}
return Collections.emptyList();
}
private static List<String> completeFirst(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (ParkourOption.JOIN.mayPerform(sender)) {
completions.add("join");
completions.add("leave");
}
if (ParkourOption.MAIN.mayPerform(sender)) {
completions.add("menu");
}
if (ParkourOption.PLAY.mayPerform(sender)) {
completions.add("play");
}
if (ParkourOption.LEADERBOARDS.mayPerform(sender)) {
completions.add("leaderboard");
}
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(List.of("schematic", "create", "reload", "forcejoin", "forceleave", "reset", "recoverinventory"));
}
return filter(input, completions);
}
private static List<String> completeSecond(CommandSender sender, String a1Raw, String input) {
String a1 = a1Raw.toLowerCase();
return switch (a1) {
case "reset" -> completeReset(sender, input);
case "join" -> completeJoin(sender, input);
case "leaderboard" -> completeLeaderboard(sender, input);
case "schematic" -> completeSchematic(sender, input);
case "create" -> completeCreate(sender, input);
case "forcejoin", "forceleave" -> completeForce(sender, a1, input);
case "recoverinventory" -> completeRecoverInventory(sender, input);
default -> Collections.emptyList();
};
}
private static List<String> completeReset(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
}
return filter(input, completions);
}
private static List<String> completeJoin(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (ParkourOption.JOIN.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
ParkourPlayer.getPlayers().forEach(pp -> completions.add(pp.getName()));
}
return filter(input, completions);
}
private static List<String> completeLeaderboard(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (ParkourOption.LEADERBOARDS.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
}
return filter(input, completions);
}
private static List<String> completeSchematic(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_SUBCOMMANDS);
}
return filter(input, completions);
}
private static List<String> completeCreate(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
return filter(input, completions);
}
private static List<String> completeForce(CommandSender sender, String a1, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
if ("forcejoin".equals(a1)) {
completions.add("nearest");
}
Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName()));
}
return filter(input, completions);
}
private static List<String> completeRecoverInventory(CommandSender sender, String input) {
List<String> completions = new ArrayList<>();
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName()));
}
return filter(input, completions);
}
private static List<String> completeThird(String a1Raw, String a2Raw, String input) {
if (!"schematic".equalsIgnoreCase(a1Raw)) {
return Collections.emptyList();
}
return completeSchematicThird(a2Raw, input);
}
private static List<String> completeSchematicThird(String a2Raw, String input) {
List<String> completions = new ArrayList<>();
if ("paste".equalsIgnoreCase(a2Raw)) {
addLoadedSchematicIds(completions);
} else if ("create".equalsIgnoreCase(a2Raw)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
return filter(input, completions);
}
private static List<String> completeFourth(String a1Raw, String a2Raw, String input) {
if ("schematic".equalsIgnoreCase(a1Raw) && "create".equalsIgnoreCase(a2Raw)) {
return filter(input, SCHEMATIC_DIFFICULTIES);
}
return Collections.emptyList();
}
private static void addLoadedSchematicIds(@NotNull List<String> completions) {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return;
}
for (String id : manager.getAll().keySet()) {
completions.add(id);
String shortId = SchematicManager.configKey(id);
if (!shortId.equalsIgnoreCase(id)) {
completions.add(shortId);
}
}
}
private static List<String> filter(String input, List<String> options) {
String lower = input.toLowerCase();
return options.stream().filter(s -> s.toLowerCase().startsWith(lower)).toList();
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.loparkour.command.core;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.admin.AdminCommandHandler;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
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}.
*/
public class LoParkourCommand implements CommandExecutor, TabCompleter {
private final PlayerCommandHandler player = new PlayerCommandHandler();
private final AdminCommandHandler admin = new AdminCommandHandler(player);
private final CommandRouter router = new CommandRouter(player, admin);
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
try {
Player p = sender instanceof Player ? (Player) sender : null;
router.dispatch(args, sender, p);
return true;
} catch (Throwable t) {
LoParkour.getPlugin().getLogger().log(Level.SEVERE,
"LoParkour command failed (" + label + " " + String.join(" ", args) + ")", t);
Locales.send(sender, "commands.error");
return true;
}
}
@Override
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
return CommandTabCompleter.complete(sender, args);
}
}

View file

@ -0,0 +1,65 @@
package dev.loki.loparkour.command.player;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.util.CommandUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.session.core.Session;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public class JoinCommandExecutor {
public void join(String arg, CommandSender sender, Player p, PlayerCommandHandler player) {
if (!player.cooldown(sender, "join", 2500) || !ParkourOption.JOIN.mayPerform(p)) {
return;
}
if (Registry.getMode(arg) instanceof Mode mode) {
mode.create(p);
return;
}
joinPlayingPlayer(arg, sender, p);
}
private void joinPlayingPlayer(String arg, CommandSender sender, Player p) {
Player other = Bukkit.getPlayer(arg);
if (other == null) {
CommandUtil.send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.unknown_player"));
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(other);
if (pp == null) {
CommandUtil.send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.not_playing"));
return;
}
joinSession(sender, p, pp.session);
}
private void joinSession(CommandSender sender, Player p, Session session) {
ParkourUser user = ParkourUser.getUser(p);
if (user != null && user.session == session) {
return;
}
if (session.isAcceptingPlayers()) {
joinAcceptingSession(p, session);
} else {
Modes.SPECTATOR.create(p, session);
}
}
private void joinAcceptingSession(Player p, Session session) {
Mode sessionMode = session.generator.getMode();
if (sessionMode instanceof MultiMode mm) {
mm.join(p, session);
} else {
Modes.SPECTATOR.create(p, session);
}
}
}

View file

@ -0,0 +1,27 @@
package dev.loki.loparkour.command.player;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.command.util.CommandUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public class LeaderboardCommandExecutor {
public void open(String arg, CommandSender sender, Player p) {
if (!ParkourOption.LEADERBOARDS.mayPerform(p)) {
CommandUtil.send(sender, Locales.getString(p, "other.no_do"));
return;
}
Mode mode = Registry.getMode(arg.toLowerCase());
if (mode == null) {
Menus.LEADERBOARDS.open(p);
} else {
Menus.SINGLE_LEADERBOARD.open(p, mode, Leaderboard.Sort.SCORE);
}
}
}

View file

@ -2,27 +2,13 @@ package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.schematic.convert.LpschemConverter;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.create.SchematicCreator;
import dev.loki.loparkour.util.particle.ParticleUtil;
import dev.loki.loparkour.util.text.ColorUtil;
import dev.loki.loparkour.util.world.Locations;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.Particle;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.BoundingBox;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.Map;
@ -38,97 +24,38 @@ public class SchematicCommandHandler {
public static final Map<Player, Location[]> SELECTIONS = new HashMap<>();
public static final NamespacedKey WAND_KEY = new NamespacedKey(LoParkour.getPlugin(), "schematic_wand");
private static ItemStack cachedWand;
@NotNull
public static ItemStack getWand() {
return buildWand().clone();
return SchematicWand.getWand();
}
public static boolean isWand(@Nullable ItemStack item) {
if (item == null || !item.hasItemMeta()) {
return false;
}
ItemMeta meta = item.getItemMeta();
return meta != null
&& meta.getPersistentDataContainer().has(WAND_KEY, PersistentDataType.BYTE);
return SchematicWand.isWand(item);
}
@NotNull
public static Location[] getSelection(@NotNull Player player) {
Location[] sel = SELECTIONS.get(player);
if (sel == null) {
sel = new Location[]{null, null};
SELECTIONS.put(player, sel);
}
return sel;
return SelectionStore.get(player);
}
public static void setPos(@NotNull Player player, @NotNull Location loc, int index) {
Location[] updated = getSelection(player).clone();
updated[index] = loc.clone();
SELECTIONS.put(player, updated);
SelectionStore.set(player, loc, index);
}
public static void clearWandCache() {
cachedWand = null;
SchematicWand.clearCache();
}
public static void sendHelp(@NotNull Player player) {
send(player, "");
send(player, Locales.getString(player, "schematic.help.header"));
send(player, "");
send(player, Locales.getString(player, "schematic.help.line_wand"));
send(player, Locales.getString(player, "schematic.help.line_pos1"));
send(player, Locales.getString(player, "schematic.help.line_pos2"));
send(player, Locales.getString(player, "schematic.help.line_create"));
send(player, Locales.getString(player, "schematic.help.line_create_named"));
send(player, Locales.getString(player, "schematic.help.line_convert"));
send(player, Locales.getString(player, "schematic.help.line_paste"));
send(player, Locales.getString(player, "schematic.help.line_list"));
send(player, Locales.getString(player, "schematic.help.line_reload"));
send(player, Locales.getString(player, "schematic.help.line_folder"));
send(player, Locales.getString(player, "schematic.help.line_docs"));
send(player, "");
SchematicMessenger.sendHelp(player);
}
public static void handleSubcommand(String sub, CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
Location playerLoc = player.getLocation();
Location[] sel = getSelection(player);
switch (sub.toLowerCase()) {
case "wand" -> {
player.getInventory().addItem(getWand());
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.wand_given"));
}
case "pos1" -> setPosCommand(player, sel, playerLoc, 0);
case "pos2" -> setPosCommand(player, sel, playerLoc, 1);
case "list" -> {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.list_header"));
LoParkour.getSchematicManager().getAll().forEach((name, s) ->
send(player, Locales.getString(player, "schematic.list_entry")
.formatted(name, s.getDifficulty(), s.getFormat().name().toLowerCase())));
}
case "reload" -> {
if (!cooldowns.cooldown(sender, "schematic-reload", 2500)) {
return;
}
Config.reload(false);
LoParkour.getSchematicManager().reload();
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.reloaded"));
}
case "convert" -> handleConvert(sender, player, cooldowns);
default -> sendHelp(player);
}
SchematicDispatcher.dispatch(sub, sender, player, cooldowns);
}
public static void handleSubcommandWithName(String sub, String name, CommandSender sender, Player player) {
switch (sub.toLowerCase()) {
case "paste" -> handlePaste(name, sender, player);
case "create" -> handleCreate(name, null, sender, player);
default -> {
}
}
SchematicDispatcher.dispatchWithName(sub, name, sender, player);
}
/** {@code schematic create <name> <difficulty>} */
@ -139,10 +66,9 @@ public class SchematicCommandHandler {
Player player,
PlayerCommandHandler cooldowns
) {
if (!cooldowns.cooldown(sender, "schematic-create", 2500)) {
return;
if (cooldowns.cooldown(sender, "schematic-create", 2500)) {
SchematicHandler.create(name, difficultyRaw, sender, player);
}
handleCreate(name, difficultyRaw, sender, player);
}
/** {@code create <difficulty>} or {@code schematic create <difficulty>} */
@ -152,123 +78,12 @@ public class SchematicCommandHandler {
Player player,
PlayerCommandHandler cooldowns
) {
if (!cooldowns.cooldown(sender, "schematic-create", 2500)) {
return;
if (cooldowns.cooldown(sender, "schematic-create", 2500)) {
SchematicHandler.create(null, difficultyRaw, sender, player);
}
handleCreate(null, difficultyRaw, sender, player);
}
private static void handleCreate(
@Nullable String optionalName,
@NotNull String difficultyRaw,
CommandSender sender,
Player player
) {
Location[] sel = SELECTIONS.get(player);
if (sel == null || sel[0] == null || sel[1] == null) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.selection_incomplete"));
return;
}
double difficulty;
try {
difficulty = SchematicCreator.parseDifficulty(difficultyRaw);
} catch (NumberFormatException e) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_difficulty"));
return;
} catch (IllegalArgumentException e) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_difficulty"));
return;
}
try {
SchematicCreator.CreateResult result = SchematicCreator.create(sel[0], sel[1], optionalName, difficulty);
LoParkour.getSchematicManager().reload();
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.created")
.formatted(result.stem(), result.difficulty(), result.file().getName()));
} catch (IllegalArgumentException e) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_name"));
} catch (Exception e) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.create_failed").formatted(e.getMessage()));
}
}
private static void handleConvert(CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
if (!cooldowns.cooldown(sender, "schematic-convert", 5000)) {
return;
}
LpschemConverter.ConvertResult result = LpschemConverter.convertAll();
if (result.converted() > 0) {
LoParkour.getSchematicManager().reload();
}
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.convert_done")
.formatted(result.converted(), result.failed()));
for (String line : result.messages()) {
if (result.messages().size() <= 12) {
send(player, "&#A0A0A0 " + line);
}
}
if (result.messages().size() > 12) {
send(player, Locales.getString(player, "schematic.convert_truncated").formatted(result.messages().size() - 12));
}
}
private static void setPosCommand(Player player, Location[] existing, Location loc, int index) {
setPos(player, loc, index);
String label = index == 0 ? "1" : "2";
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.pos_set")
.formatted(label, Locations.toString(loc, true)));
Location[] updated = getSelection(player);
if (updated[0] != null && updated[1] != null) {
ParticleUtil.box(BoundingBox.of(updated[0], updated[1]), player.getWorld(), Particle.END_ROD, player, 0.2);
}
}
private static void handlePaste(String name, CommandSender sender, Player player) {
if (!isValidSchematicName(name)) {
send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_name"));
return;
}
ParkourSchematic schematic = LoParkour.getSchematicManager().get(name);
if (schematic == null) {
send(sender, LoParkour.PREFIX + Locales.getString(sender, "schematic.not_found").formatted(name));
return;
}
schematic.paste(player.getLocation(), player.getWorld());
send(sender, LoParkour.PREFIX + Locales.getString(sender, "schematic.pasted").formatted(name));
}
static boolean isValidSchematicName(String name) {
if (name == null || name.isEmpty() || name.length() > 64) {
return false;
}
return name.matches("^[a-zA-Z0-9_-]+$");
return SchematicHandler.isValidSchematicName(name);
}
@NotNull
private static ItemStack buildWand() {
if (cachedWand != null) {
return cachedWand;
}
String locale = Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
if (locale == null || locale.isBlank()) {
locale = "en";
}
ItemStack item = Locales.getItem(locale, "schematic.wand").build();
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.getPersistentDataContainer().set(WAND_KEY, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
}
cachedWand = item;
return item;
}
private static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}
}

View file

@ -0,0 +1,54 @@
package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class SchematicDispatcher {
private SchematicDispatcher() {
}
static void dispatch(String sub, CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
Location playerLoc = player.getLocation();
Location[] sel = SchematicCommandHandler.getSelection(player);
switch (sub.toLowerCase()) {
case "wand" -> {
player.getInventory().addItem(SchematicCommandHandler.getWand());
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.wand_given"));
}
case "pos1" -> SchematicHandler.posCommand(player, sel, playerLoc, 0);
case "pos2" -> SchematicHandler.posCommand(player, sel, playerLoc, 1);
case "list" -> {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.list_header"));
LoParkour.getSchematicManager().getAll().forEach((name, s) ->
SchematicMessenger.send(player, Locales.getString(player, "schematic.list_entry")
.formatted(name, s.getDifficulty(), s.getFormat().name().toLowerCase())));
}
case "reload" -> {
if (!cooldowns.cooldown(sender, "schematic-reload", 2500)) {
return;
}
Config.reload(false);
LoParkour.getSchematicManager().reload();
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.reloaded"));
}
case "convert" -> SchematicHandler.convert(sender, player, cooldowns);
default -> SchematicMessenger.sendHelp(player);
}
}
static void dispatchWithName(String sub, String name, CommandSender sender, Player player) {
switch (sub.toLowerCase()) {
case "paste" -> SchematicHandler.paste(name, sender, player);
case "create" -> SchematicHandler.create(name, null, sender, player);
default -> {
}
}
}
}

View file

@ -0,0 +1,115 @@
package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.player.PlayerCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.schematic.convert.LpschemConverter;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.create.SchematicCreator;
import dev.loki.loparkour.util.particle.ParticleUtil;
import dev.loki.loparkour.util.world.Locations;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.util.BoundingBox;
import java.io.IOException;
final class SchematicHandler {
private SchematicHandler() {
}
static void create(
@Nullable String optionalName,
@NotNull String difficultyRaw,
CommandSender sender,
Player player
) {
Location[] sel = SchematicCommandHandler.SELECTIONS.get(player);
if (sel == null || sel[0] == null || sel[1] == null) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.selection_incomplete"));
return;
}
double difficulty;
try {
difficulty = SchematicCreator.parseDifficulty(difficultyRaw);
} catch (IllegalArgumentException e) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_difficulty"));
return;
}
try {
SchematicCreator.CreateResult result = SchematicCreator.create(sel[0], sel[1], optionalName, difficulty);
LoParkour.getSchematicManager().reload();
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.created")
.formatted(result.stem(), result.difficulty(), result.file().getName()));
} catch (IllegalArgumentException e) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_name"));
} catch (IOException e) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.create_failed")
.formatted(e.getMessage()));
}
}
static void paste(String name, CommandSender sender, Player player) {
if (!isValidSchematicName(name)) {
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.invalid_name"));
return;
}
ParkourSchematic schematic = LoParkour.getSchematicManager().get(name);
if (schematic == null) {
SchematicMessenger.send(sender, LoParkour.PREFIX + Locales.getString(sender, "schematic.not_found")
.formatted(name));
return;
}
schematic.paste(player.getLocation(), player.getWorld());
SchematicMessenger.send(sender, LoParkour.PREFIX + Locales.getString(sender, "schematic.pasted")
.formatted(name));
}
static void convert(CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
if (!cooldowns.cooldown(sender, "schematic-convert", 5000)) {
return;
}
LpschemConverter.ConvertResult result = LpschemConverter.convertAll();
if (result.converted() > 0) {
LoParkour.getSchematicManager().reload();
}
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.convert_done")
.formatted(result.converted(), result.failed()));
for (String line : result.messages()) {
if (result.messages().size() <= 12) {
SchematicMessenger.send(player, "&#A0A0A0 " + line);
}
}
if (result.messages().size() > 12) {
SchematicMessenger.send(player, Locales.getString(player, "schematic.convert_truncated")
.formatted(result.messages().size() - 12));
}
}
static void posCommand(Player player, Location[] existing, Location loc, int index) {
SchematicCommandHandler.setPos(player, loc, index);
String label = index == 0 ? "1" : "2";
SchematicMessenger.send(player, LoParkour.PREFIX + Locales.getString(player, "schematic.pos_set")
.formatted(label, Locations.toString(loc, true)));
Location[] updated = SchematicCommandHandler.getSelection(player);
if (updated[0] != null && updated[1] != null) {
ParticleUtil.box(BoundingBox.of(updated[0], updated[1]), player.getWorld(), Particle.END_ROD, player, 0.2);
}
}
static boolean isValidSchematicName(String name) {
if (name == null || name.isEmpty() || name.length() > 64) {
return false;
}
return name.matches("^[a-zA-Z0-9_-]+$");
}
}

View file

@ -0,0 +1,35 @@
package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
final class SchematicMessenger {
private SchematicMessenger() {
}
static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
static void sendHelp(@NotNull Player player) {
send(player, "");
send(player, Locales.getString(player, "schematic.help.header"));
send(player, "");
send(player, Locales.getString(player, "schematic.help.line_wand"));
send(player, Locales.getString(player, "schematic.help.line_pos1"));
send(player, Locales.getString(player, "schematic.help.line_pos2"));
send(player, Locales.getString(player, "schematic.help.line_create"));
send(player, Locales.getString(player, "schematic.help.line_create_named"));
send(player, Locales.getString(player, "schematic.help.line_convert"));
send(player, Locales.getString(player, "schematic.help.line_paste"));
send(player, Locales.getString(player, "schematic.help.line_list"));
send(player, Locales.getString(player, "schematic.help.line_reload"));
send(player, Locales.getString(player, "schematic.help.line_folder"));
send(player, Locales.getString(player, "schematic.help.line_docs"));
send(player, "");
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.loparkour.command.schematic;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.menu.core.ParkourOption;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
final class SchematicWand {
private static ItemStack cachedWand;
private SchematicWand() {
}
@NotNull
static ItemStack getWand() {
return build().clone();
}
static boolean isWand(@Nullable ItemStack item) {
if (item == null || !item.hasItemMeta()) {
return false;
}
ItemMeta meta = item.getItemMeta();
return meta != null
&& meta.getPersistentDataContainer().has(SchematicCommandHandler.WAND_KEY, PersistentDataType.BYTE);
}
static void clearCache() {
cachedWand = null;
}
@NotNull
private static ItemStack build() {
if (cachedWand != null) {
return cachedWand;
}
String locale = Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
if (locale == null || locale.isBlank()) {
locale = "en";
}
ItemStack item = Locales.getItem(locale, "schematic.wand").build();
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.getPersistentDataContainer().set(SchematicCommandHandler.WAND_KEY, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
}
cachedWand = item;
return item;
}
}

View file

@ -0,0 +1,27 @@
package dev.loki.loparkour.command.schematic;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
final class SelectionStore {
private SelectionStore() {
}
@NotNull
static Location[] get(@NotNull Player player) {
Location[] sel = SchematicCommandHandler.SELECTIONS.get(player);
if (sel == null) {
sel = new Location[]{null, null};
SchematicCommandHandler.SELECTIONS.put(player, sel);
}
return sel;
}
static void set(@NotNull Player player, @NotNull Location loc, int index) {
Location[] updated = get(player).clone();
updated[index] = loc.clone();
SchematicCommandHandler.SELECTIONS.put(player, updated);
}
}

View file

@ -0,0 +1,14 @@
package dev.loki.loparkour.command.util;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.command.CommandSender;
public final class CommandUtil {
private CommandUtil() {
}
public static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -35,14 +35,13 @@ public class ConfigLoader {
}
public static void updateConfig(@NotNull String fileName, @NotNull File configFile, @Nullable List<String> ignoredSections) {
try {
var plugin = LoParkour.getPlugin();
var resourceStream = plugin.getResource(fileName);
var plugin = LoParkour.getPlugin();
try (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,
plugin.getLogger().log(java.util.logging.Level.SEVERE,
"Error while trying to update config file: " + fileName, ex);
}
}

View file

@ -0,0 +1,108 @@
package dev.loki.loparkour.config.core;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Reads YAML as raw lines and extracts the full path of each scalar value
* (e.g. "sql.enabled") so that user values can be matched against the template.
*/
final class ConfigMergeParser {
@NotNull
List<String> readLines(@NotNull InputStream is) throws IOException {
try (var r = new BufferedReader(new InputStreamReader(is))) {
return r.lines().toList();
}
}
@NotNull
List<String> readLines(@NotNull File file) throws IOException {
return Files.readAllLines(file.toPath());
}
@NotNull
Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
Map<String, String> values = new LinkedHashMap<>();
Deque<String> stack = new ArrayDeque<>();
int prevIndent = -1;
for (String line : lines) {
LineTokens tokens = parseLine(line);
if (tokens == null) {
continue;
}
prevIndent = popToLevel(stack, tokens.indent(), prevIndent);
String fullPath = resolvePath(stack, tokens.leaf());
if (tokens.isSection()) {
stack.push(tokens.leaf());
} else {
values.put(fullPath, tokens.after());
}
prevIndent = tokens.indent();
}
return values;
}
@Nullable
static LineTokens parseLine(@NotNull String line) {
if (isSkippable(line)) {
return null;
}
String trimmed = line.trim();
int colon = trimmed.indexOf(':');
if (colon == -1) {
return null;
}
String leaf = trimmed.substring(0, colon).trim();
String after = trimmed.substring(colon + 1).trim();
return new LineTokens(leaf, after, indentOf(line), isSection(after));
}
private static boolean isSkippable(@NotNull String line) {
String trimmed = line.trim();
return trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-");
}
private static boolean isSection(@NotNull String after) {
return after.isEmpty() || after.startsWith("#");
}
static int popToLevel(@NotNull Deque<String> stack, int indent, int prevIndent) {
while (prevIndent >= 0 && indent <= prevIndent) {
if (!stack.isEmpty()) {
stack.pop();
}
prevIndent -= 2;
}
return prevIndent;
}
@NotNull
static String resolvePath(@NotNull Deque<String> stack, @NotNull String leaf) {
return stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
}
static int indentOf(@NotNull String line) {
int i = 0;
while (i < line.length() && line.charAt(i) == ' ') {
i++;
}
return i;
}
record LineTokens(String leaf, String after, int indent, boolean isSection) {
}
}

View file

@ -0,0 +1,96 @@
package dev.loki.loparkour.config.core;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Builds the merged file by walking the template and substituting any scalar
* value that the user has already customized, preserving comments and sections.
*/
final class ConfigMergeProcessor {
private final ConfigMergeParser parser = new ConfigMergeParser();
@NotNull
List<String> merge(
@NotNull InputStream templateStream,
@NotNull File existingFile,
@Nullable List<String> ignoredKeys) throws IOException {
List<String> templateLines = parser.readLines(templateStream);
List<String> existingLines = existingFile.exists() ? parser.readLines(existingFile) : List.of();
Set<String> ignored = normalizeIgnored(ignoredKeys);
Map<String, String> existingValues = parser.extractFullPathValues(existingLines);
List<String> result = new ArrayList<>();
Deque<String> stack = new ArrayDeque<>();
int prevIndent = -1;
for (String line : templateLines) {
ConfigMergeParser.LineTokens tokens = ConfigMergeParser.parseLine(line);
if (tokens == null) {
result.add(line);
continue;
}
prevIndent = ConfigMergeParser.popToLevel(stack, tokens.indent(), prevIndent);
String fullPath = ConfigMergeParser.resolvePath(stack, tokens.leaf());
if (tokens.isSection()) {
stack.push(tokens.leaf());
result.add(line);
} else if (isIgnored(fullPath, ignored)) {
result.add(line);
} else {
result.add(applyExistingValue(line, fullPath, existingValues));
}
prevIndent = tokens.indent();
}
return result;
}
@NotNull
private static String applyExistingValue(
@NotNull String line,
@NotNull String fullPath,
@NotNull Map<String, String> existingValues) {
String existing = existingValues.get(fullPath);
if (existing == null) {
return line;
}
return line.substring(0, line.indexOf(':') + 1) + " " + existing;
}
@NotNull
private static Set<String> normalizeIgnored(@Nullable List<String> keys) {
if (keys == null) {
return Set.of();
}
Set<String> s = new HashSet<>();
for (String k : keys) {
s.add(k.trim().toLowerCase());
}
return s;
}
private static boolean isIgnored(@NotNull String fullPath, @NotNull Set<String> ignored) {
String lower = fullPath.toLowerCase();
for (String ign : ignored) {
if (lower.equals(ign) || lower.startsWith(ign + ".")) {
return true;
}
}
return false;
}
}

View file

@ -3,21 +3,12 @@ package dev.loki.loparkour.config.core;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
/**
@ -52,7 +43,7 @@ public class ConfigUpdater {
File backup = createBackup(configFile);
try {
List<String> merged = merge(templateStream, configFile, ignoredKeys);
List<String> merged = new ConfigMergeProcessor().merge(templateStream, configFile, ignoredKeys);
Files.write(configFile.toPath(), merged);
logger.info("Configuration file synced: " + configFile.getName());
} catch (Exception e) {
@ -64,120 +55,6 @@ public class ConfigUpdater {
}
}
// ── merge ─────────────────────────────────────────────────────────────────
@NotNull
private List<String> merge(
@NotNull InputStream templateStream,
@NotNull File existingFile,
@Nullable List<String> ignoredKeys) throws IOException {
List<String> templateLines = readLines(templateStream);
List<String> existingLines = existingFile.exists() ? readLines(existingFile) : List.of();
Set<String> ignored = normalizeIgnored(ignoredKeys);
Map<String, String> existingValues = extractFullPathValues(existingLines);
List<String> result = new ArrayList<>();
Deque<String> 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;
}
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;
}
String fullPath = stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
boolean section = after.isEmpty() || after.startsWith("#");
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;
}
}
return result;
}
@NotNull
private Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
Map<String, String> values = new LinkedHashMap<>();
Deque<String> stack = new ArrayDeque<>();
int prevIndent = -1;
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
continue;
}
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;
}
return values;
}
// ── helpers ───────────────────────────────────────────────────────────────
private static int indentOf(@NotNull String line) {
int i = 0;
while (i < line.length() && line.charAt(i) == ' ') {
i++;
}
return i;
}
@Nullable
private File createBackup(@NotNull File file) {
if (!file.exists()) {
@ -192,38 +69,4 @@ public class ConfigUpdater {
return null;
}
}
@NotNull
private static List<String> readLines(@NotNull InputStream is) throws IOException {
try (var r = new BufferedReader(new InputStreamReader(is))) {
return r.lines().toList();
}
}
@NotNull
private static List<String> readLines(@NotNull File file) throws IOException {
return Files.readAllLines(file.toPath());
}
@NotNull
private static Set<String> normalizeIgnored(@Nullable List<String> keys) {
if (keys == null) {
return Set.of();
}
Set<String> s = new HashSet<>();
for (String k : keys) {
s.add(k.trim().toLowerCase());
}
return s;
}
private static boolean isIgnored(@NotNull String fullPath, @NotNull Set<String> ignored) {
String lower = fullPath.toLowerCase();
for (String ign : ignored) {
if (lower.equals(ign) || lower.startsWith(ign + ".")) {
return true;
}
}
return false;
}
}
}

View file

@ -1,40 +1,34 @@
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.jump.calculation.JumpCalculator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.util.misc.Probs;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
/**
* Refactored BlockPlacer using composition pattern.
* Coordinates block selection, jump calculation, and placement.
* Facade coordinating block selection, jump calculation, and placement.
*/
public class BlockPlacer {
private final ParkourGenerator generator;
private final BlockSelector blockSelector;
private final JumpCalculator jumpCalculator;
private final BlockTargetSelector targetSelector;
private final BlockWorldPlacer worldPlacer;
private final SchematicBlockPlacer schematicPlacer;
public BlockPlacer(@NotNull ParkourGenerator generator) {
this.generator = generator;
this.blockSelector = new BlockSelector(generator);
this.jumpCalculator = new JumpCalculator(generator, blockSelector);
this.targetSelector = new BlockTargetSelector(generator, blockSelector, new JumpCalculator(generator, blockSelector));
this.worldPlacer = new BlockWorldPlacer(generator);
this.schematicPlacer = new SchematicBlockPlacer(generator, targetSelector);
}
/**
* Generate a single block.
*/
@ -51,85 +45,60 @@ public class BlockPlacer {
}
}
private boolean isTallMaterial(@NotNull Material mat) {
return blockSelector.isFenceMaterial(mat) || blockSelector.isTrapdoorMaterial(mat) || mat == Material.GLASS_PANE;
}
/**
* Select blocks with a forced height override for tall materials (fences, trapdoors).
*/
@NotNull
private List<Block> selectBlocks(int forcedHeight) {
List<Block> blocks = new ArrayList<>();
if (generator.state.history.isEmpty()) {
return blocks;
}
Block current = generator.getLatest();
int distance = Probs.random(generator.state.distanceChances);
int height = forcedHeight != Integer.MIN_VALUE ? forcedHeight : Probs.random(generator.state.heightChances);
Block nextBlock = jumpCalculator.calculateNextBlock(current, distance, height);
blocks.add(nextBlock);
return blocks;
}
/**
* Generate the first block at specified locations.
*/
public void generateFirst(@NotNull Location spawn, @NotNull Location blockSpawn) {
Block block = blockSpawn.getBlock();
BlockData blockData = blockSelector.selectBlockData();
placeBlockData(block, blockData);
worldPlacer.place(block, blockData);
generator.state.history.add(block);
generator.state.playerSpawn = spawn;
}
/**
* Delete schematic blocks (if any).
*/
public void deleteSchematic() {
if (generator.state.schematicBlocks != null) {
generator.state.schematicBlocks.forEach(block -> block.setType(org.bukkit.Material.AIR));
generator.state.schematicBlocks.clear();
}
worldPlacer.deleteSchematic();
}
private void generateSingleBlock() {
ParkourGenerator.BlockGenerationType jumpType = selectGenerationType();
switch (jumpType) {
case DEFAULT -> placeNormalBlock();
case SPECIAL -> placeSpecialBlock();
case SCHEMATIC -> {
if (!tryGenerateSchematic()) {
placeNormalBlock(); // Fallback to normal block
if (!schematicPlacer.placeSchematic()) {
placeNormalBlock();
}
}
default -> {}
default -> {
}
}
}
@NotNull
private ParkourGenerator.BlockGenerationType selectGenerationType() {
return Probs.random(generator.state.defaultChances);
}
private void placeNormalBlock() {
BlockData blockData = blockSelector.selectBlockData();
if (blockData == null) {
return;
}
Material mat = blockData.getMaterial();
boolean tall = isTallMaterial(mat);
List<Block> blocks = tall ? selectBlocks(-1) : selectBlocks();
boolean tall = targetSelector.isTallMaterial(blockData.getMaterial());
List<Block> blocks = tall ? targetSelector.selectBlocks(-1) : targetSelector.selectBlocks();
if (blocks.isEmpty()) {
return;
}
Block selectedBlock = blocks.get(0);
placeBlockData(selectedBlock, blockData);
worldPlacer.place(selectedBlock, blockData);
generator.state.history.add(selectedBlock);
}
@ -140,88 +109,14 @@ public class BlockPlacer {
return;
}
Material mat = specialBlockData.getMaterial();
boolean tall = isTallMaterial(mat);
List<Block> blocks = tall ? selectBlocks(-1) : selectBlocks();
boolean tall = targetSelector.isTallMaterial(specialBlockData.getMaterial());
List<Block> blocks = tall ? targetSelector.selectBlocks(-1) : targetSelector.selectBlocks();
if (blocks.isEmpty()) {
return;
}
Block selectedBlock = blocks.get(0);
placeBlockData(selectedBlock, specialBlockData);
worldPlacer.place(selectedBlock, specialBlockData);
generator.state.history.add(selectedBlock);
}
/** Prefer no neighbor physics to avoid fragile vanilla multi-block checks in empty worlds. */
private void placeBlockData(@NotNull Block block, @NotNull BlockData data) {
block.setBlockData(data, false);
// Play effects for all players
Location loc = block.getLocation().add(0.5, 0.5, 0.5);
for (ParkourPlayer player : generator.getPlayers()) {
// Spawn particles
player.player.spawnParticle(Particle.BLOCK_CRACK, loc, 10, 0.3, 0.3, 0.3, 0.1, data);
// Play sound
player.player.playSound(loc, Sound.BLOCK_STONE_PLACE, 0.5f, 1.0f);
}
}
@NotNull
private List<Block> selectBlocks() {
List<Block> blocks = new ArrayList<>();
if (generator.state.history.isEmpty()) {
return blocks; // No previous blocks to work with
}
Block current = generator.getLatest();
// Select jump parameters
int distance = Probs.random(generator.state.distanceChances);
int height = Probs.random(generator.state.heightChances);
// Calculate next block position
Block nextBlock = jumpCalculator.calculateNextBlock(current, distance, height);
blocks.add(nextBlock);
return blocks;
}
private boolean tryGenerateSchematic() {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return false;
}
double maxDifficulty = generator.getPlayers().stream()
.mapToDouble(p -> p.schematicDifficulty)
.min()
.orElse(1.0);
ParkourSchematic schematic = manager.pick(maxDifficulty);
if (schematic == null) {
return false;
}
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) {
return false;
}
Location pasteLocation = blocks.get(0).getLocation();
List<Block> schematicBlocks = schematic.paste(pasteLocation, pasteLocation.getWorld());
if (schematicBlocks.isEmpty()) {
return false;
}
generator.state.history.addAll(schematicBlocks);
generator.state.schematicBlocks = schematicBlocks;
for (ParkourPlayer player : generator.getPlayers()) {
new ParkourSchematicGenerateEvent(schematic, generator, player).call();
}
return true;
}
}
}

View file

@ -0,0 +1,52 @@
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.jump.calculation.JumpCalculator;
import dev.loki.loparkour.util.misc.Probs;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
/**
* Computes the target block position for generated parkour blocks based on jump parameters.
*/
class BlockTargetSelector {
private static final int NO_FORCED_HEIGHT = Integer.MIN_VALUE;
private final ParkourGenerator generator;
private final BlockSelector blockSelector;
private final JumpCalculator jumpCalculator;
BlockTargetSelector(@NotNull ParkourGenerator generator, @NotNull BlockSelector blockSelector, @NotNull JumpCalculator jumpCalculator) {
this.generator = generator;
this.blockSelector = blockSelector;
this.jumpCalculator = jumpCalculator;
}
boolean isTallMaterial(@NotNull Material mat) {
return blockSelector.isFenceMaterial(mat) || blockSelector.isTrapdoorMaterial(mat) || mat == Material.GLASS_PANE;
}
@NotNull
List<Block> selectBlocks() {
return selectBlocks(NO_FORCED_HEIGHT);
}
@NotNull
List<Block> selectBlocks(int forcedHeight) {
List<Block> blocks = new ArrayList<>();
if (generator.state.history.isEmpty()) {
return blocks;
}
Block current = generator.getLatest();
int distance = Probs.random(generator.state.distanceChances);
int height = forcedHeight != NO_FORCED_HEIGHT ? forcedHeight : Probs.random(generator.state.heightChances);
Block nextBlock = jumpCalculator.calculateNextBlock(current, distance, height);
blocks.add(nextBlock);
return blocks;
}
}

View file

@ -0,0 +1,40 @@
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
/**
* Applies block data to the world and plays placement effects for all players.
*/
class BlockWorldPlacer {
private final ParkourGenerator generator;
BlockWorldPlacer(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
void place(@NotNull Block block, @NotNull BlockData data) {
block.setBlockData(data, false);
Location loc = block.getLocation().add(0.5, 0.5, 0.5);
for (ParkourPlayer player : generator.getPlayers()) {
player.player.spawnParticle(Particle.BLOCK_CRACK, loc, 10, 0.3, 0.3, 0.3, 0.1, data);
player.player.playSound(loc, Sound.BLOCK_STONE_PLACE, 0.5f, 1.0f);
}
}
void deleteSchematic() {
if (generator.state.schematicBlocks != null) {
generator.state.schematicBlocks.forEach(block -> block.setType(Material.AIR));
generator.state.schematicBlocks.clear();
}
}
}

View file

@ -0,0 +1,63 @@
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import dev.loki.loparkour.schematic.core.SchematicManager;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* Pastes schematic parkour sections into the world and fires the related event.
*/
class SchematicBlockPlacer {
private final ParkourGenerator generator;
private final BlockTargetSelector targetSelector;
SchematicBlockPlacer(@NotNull ParkourGenerator generator, @NotNull BlockTargetSelector targetSelector) {
this.generator = generator;
this.targetSelector = targetSelector;
}
boolean placeSchematic() {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return false;
}
double maxDifficulty = generator.getPlayers().stream()
.mapToDouble(p -> p.schematicDifficulty)
.min()
.orElse(1.0);
ParkourSchematic schematic = manager.pick(maxDifficulty);
if (schematic == null) {
return false;
}
List<Block> blocks = targetSelector.selectBlocks();
if (blocks.isEmpty()) {
return false;
}
Location pasteLocation = blocks.get(0).getLocation();
List<Block> schematicBlocks = schematic.paste(pasteLocation, pasteLocation.getWorld());
if (schematicBlocks.isEmpty()) {
return false;
}
generator.state.history.addAll(schematicBlocks);
generator.state.schematicBlocks = schematicBlocks;
for (ParkourPlayer player : generator.getPlayers()) {
new ParkourSchematicGenerateEvent(schematic, generator, player).call();
}
return true;
}
}

View file

@ -1,26 +1,13 @@
package dev.loki.loparkour.hook.papi;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.spectator.ParkourSpectator;
import dev.loki.loparkour.player.core.ParkourUser;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PAPIHook extends PlaceholderExpansion {
private static final Pattern INFINITE_REGEX = Pattern.compile("(.+)_(\\d+)");
private final PlaceholderDispatcher dispatcher = new PlaceholderDispatcher();
@Override
public @NotNull String getIdentifier() {
@ -48,173 +35,7 @@ public class PAPIHook extends PlaceholderExpansion {
}
@Override
@SuppressWarnings({"checkstyle:MethodLength", "checkstyle:CyclomaticComplexity"})
public String onPlaceholderRequest(Player player, @NotNull String params) {
// placeholders that don't require a player
switch (params) {
case "version", "ver" -> {
return LoParkour.getPlugin().getDescription().getVersion();
}
case "leader", "record_player" -> {
Score score = Modes.DEFAULT.getLeaderboard().getScoreAtRank(1);
return score != null ? score.name() : "?";
}
case "leader_score", "record_score", "record" -> {
Score score = Modes.DEFAULT.getLeaderboard().getScoreAtRank(1);
return score != null ? Integer.toString(score.score()) : "?";
}
default -> {}
}
if (params.contains("player_rank_")) {
return getInfiniteScore(params.replace("player_rank_", ""), Score::name);
} else if (params.contains("score_rank_")) {
return getInfiniteScore(params.replace("score_rank_", ""), Score::score);
} else if (params.contains("time_rank_")) {
return getInfiniteScore(params.replace("time_rank_", ""), Score::time);
} else if (params.contains("difficulty_rank_")) {
return getInfiniteScore(params.replace("difficulty_rank_", ""), Score::difficulty);
} else if (params.contains("difficulty_string_rank_")) {
return getInfiniteScore(params.replace("difficulty_string_rank_", ""),
score -> {
String diff = score.difficulty();
if ("?".equals(diff)) {
return parseDifficulty(2.0);
}
try {
return parseDifficulty(Double.parseDouble(diff));
} catch (NumberFormatException e) {
return parseDifficulty(2.0);
}
});
}
// placeholders that require player
if (player == null) {
return "player doesn't exist";
}
switch (params) {
case "rank" -> {
return Integer.toString(Modes.DEFAULT.getLeaderboard().getRank(player.getUniqueId()));
}
case "highscore", "high_score" -> {
return Integer.toString(Modes.DEFAULT.getLeaderboard().get(player.getUniqueId()).score());
}
case "high_score_time" -> {
return Modes.DEFAULT.getLeaderboard().get(player.getUniqueId()).time();
}
default -> {}
}
ParkourUser user = ParkourUser.getUser(player);
ParkourPlayer pp = null;
if (user instanceof ParkourPlayer) {
pp = (ParkourPlayer) user;
} else if (user instanceof ParkourSpectator) {
pp = ((ParkourSpectator) user).closest;
}
if (pp != null && pp.session.generator != null) {
ParkourGenerator generator = pp.session.generator;
switch (params) {
case "score", "current_score" -> {
return Integer.toString(generator.state.score);
}
case "time", "current_time" -> {
return generator.getFormattedTime();
}
case "blocklead", "lead" -> {
return Integer.toString(pp.blockLead);
}
case "style" -> {
return pp.style;
}
case "time_pref", "time_preference" -> {
return Integer.toString(pp.selectedTime);
}
case "scoreboard" -> {
return pp.showScoreboard.toString();
}
case "difficulty" -> {
return Double.toString(pp.schematicDifficulty);
}
case "difficulty_string" -> {
return parseDifficulty(pp.schematicDifficulty);
}
default -> {
if (params.contains("score_until_")) {
String replaced = params.replace("score_until_", "");
try {
int interval = Integer.parseInt(replaced);
if (interval > 0) {
return Integer.toString(interval - (generator.state.score % interval));
} else {
return "0";
}
} catch (NumberFormatException e) {
return "0";
}
}
}
}
}
return null;
return dispatcher.dispatch(player, params);
}
private String parseDifficulty(double difficulty) {
if (difficulty <= 0.25) {
return "easy";
} else if (difficulty <= 0.5) {
return "medium";
} else if (difficulty <= 0.75) {
return "hard";
} else if (difficulty <= 1) {
return "very hard";
}
return "?";
}
private String getInfiniteScore(String rankData, Function<Score, ?> f) {
int rank;
Leaderboard leaderboard = null;
Matcher matcher = INFINITE_REGEX.matcher(rankData);
try {
// use mode-specific format
// x_mode_rank
if (matcher.matches()) {
String name = matcher.group(1);
rank = Integer.parseInt(matcher.group(2));
Mode mode = Registry.getMode(name);
if (mode != null) {
leaderboard = mode.getLeaderboard();
}
// use generic format
// x_rank
} else {
rank = Integer.parseInt(rankData);
}
} catch (NumberFormatException e) {
return "?";
}
if (leaderboard == null) {
leaderboard = Modes.DEFAULT.getLeaderboard();
}
if (rank > 0) {
Score score = leaderboard.getScoreAtRank(rank);
if (score == null) {
return "?";
} else {
return String.valueOf(f.apply(score));
}
} else {
return "?";
}
}
}
}

View file

@ -0,0 +1,23 @@
package dev.loki.loparkour.hook.papi;
import dev.loki.loparkour.hook.papi.resolver.GlobalPlaceholderResolver;
import dev.loki.loparkour.hook.papi.resolver.PlayerPlaceholderResolver;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
class PlaceholderDispatcher {
private final GlobalPlaceholderResolver globalResolver = new GlobalPlaceholderResolver();
private final PlayerPlaceholderResolver playerResolver = new PlayerPlaceholderResolver();
String dispatch(Player player, @NotNull String params) {
String resolved = globalResolver.resolve(params);
if (resolved != null) {
return resolved;
}
if (player == null) {
return "player doesn't exist";
}
return playerResolver.resolve(player, params);
}
}

View file

@ -0,0 +1,58 @@
package dev.loki.loparkour.hook.papi.resolver;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.mode.base.Modes;
public class GlobalPlaceholderResolver {
private final ScoreRankResolver rankResolver = new ScoreRankResolver();
private final PlaceholderFormatters formatters = new PlaceholderFormatters();
public String resolve(String params) {
String value = resolveStatic(params);
if (value != null) {
return value;
}
return resolveRanked(params);
}
private String resolveStatic(String params) {
switch (params) {
case "version", "ver" -> {
return LoParkour.getPlugin().getDescription().getVersion();
}
case "leader", "record_player" -> {
Score score = Modes.DEFAULT.getLeaderboard().getScoreAtRank(1);
return score != null ? score.name() : "?";
}
case "leader_score", "record_score", "record" -> {
Score score = Modes.DEFAULT.getLeaderboard().getScoreAtRank(1);
return score != null ? Integer.toString(score.score()) : "?";
}
default -> {
return null;
}
}
}
private String resolveRanked(String params) {
if (params.contains("player_rank_")) {
return rankResolver.resolve(params.replace("player_rank_", ""), Score::name);
}
if (params.contains("score_rank_")) {
return rankResolver.resolve(params.replace("score_rank_", ""), Score::score);
}
if (params.contains("time_rank_")) {
return rankResolver.resolve(params.replace("time_rank_", ""), Score::time);
}
if (params.contains("difficulty_rank_")) {
return rankResolver.resolve(params.replace("difficulty_rank_", ""), Score::difficulty);
}
if (params.contains("difficulty_string_rank_")) {
return rankResolver.resolve(params.replace("difficulty_string_rank_", ""),
formatters::difficultyString);
}
return null;
}
}

View file

@ -0,0 +1,31 @@
package dev.loki.loparkour.hook.papi.resolver;
import dev.loki.loparkour.leaderboard.model.Score;
class PlaceholderFormatters {
String parseDifficulty(double difficulty) {
if (difficulty <= 0.25) {
return "easy";
} else if (difficulty <= 0.5) {
return "medium";
} else if (difficulty <= 0.75) {
return "hard";
} else if (difficulty <= 1) {
return "very hard";
}
return "?";
}
String difficultyString(Score score) {
String diff = score.difficulty();
if ("?".equals(diff)) {
return parseDifficulty(2.0);
}
try {
return parseDifficulty(Double.parseDouble(diff));
} catch (NumberFormatException e) {
return parseDifficulty(2.0);
}
}
}

View file

@ -0,0 +1,104 @@
package dev.loki.loparkour.hook.papi.resolver;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.player.spectator.ParkourSpectator;
import org.bukkit.entity.Player;
public class PlayerPlaceholderResolver {
private final PlaceholderFormatters formatters = new PlaceholderFormatters();
public String resolve(Player player, String params) {
String value = resolveLeaderboard(player, params);
if (value != null) {
return value;
}
return resolveSession(player, params);
}
private String resolveLeaderboard(Player player, String params) {
switch (params) {
case "rank" -> {
return Integer.toString(Modes.DEFAULT.getLeaderboard().getRank(player.getUniqueId()));
}
case "highscore", "high_score" -> {
return Integer.toString(Modes.DEFAULT.getLeaderboard().get(player.getUniqueId()).score());
}
case "high_score_time" -> {
return Modes.DEFAULT.getLeaderboard().get(player.getUniqueId()).time();
}
default -> {
return null;
}
}
}
private String resolveSession(Player player, String params) {
ParkourUser user = ParkourUser.getUser(player);
ParkourPlayer pp = extractParkourPlayer(user);
if (pp == null || pp.session.generator == null) {
return null;
}
return resolveGenerator(pp, params);
}
private ParkourPlayer extractParkourPlayer(ParkourUser user) {
if (user instanceof ParkourPlayer) {
return (ParkourPlayer) user;
}
if (user instanceof ParkourSpectator) {
return ((ParkourSpectator) user).closest;
}
return null;
}
private String resolveGenerator(ParkourPlayer pp, String params) {
switch (params) {
case "score", "current_score" -> {
return Integer.toString(pp.session.generator.state.score);
}
case "time", "current_time" -> {
return pp.session.generator.getFormattedTime();
}
case "blocklead", "lead" -> {
return Integer.toString(pp.blockLead);
}
case "style" -> {
return pp.style;
}
case "time_pref", "time_preference" -> {
return Integer.toString(pp.selectedTime);
}
case "scoreboard" -> {
return pp.showScoreboard.toString();
}
case "difficulty" -> {
return Double.toString(pp.schematicDifficulty);
}
case "difficulty_string" -> {
return formatters.parseDifficulty(pp.schematicDifficulty);
}
default -> {
return resolveScoreUntil(pp, params);
}
}
}
private String resolveScoreUntil(ParkourPlayer pp, String params) {
if (!params.contains("score_until_")) {
return null;
}
String replaced = params.replace("score_until_", "");
try {
int interval = Integer.parseInt(replaced);
if (interval > 0) {
return Integer.toString(interval - (pp.session.generator.state.score % interval));
}
return "0";
} catch (NumberFormatException e) {
return "0";
}
}
}

View file

@ -0,0 +1,59 @@
package dev.loki.loparkour.hook.papi.resolver;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
class ScoreRankResolver {
private static final Pattern INFINITE_REGEX = Pattern.compile("(.+)_(\\d+)");
String resolve(String rankData, Function<Score, ?> valueFunction) {
Matcher matcher = INFINITE_REGEX.matcher(rankData);
if (matcher.matches()) {
return resolveModeRank(matcher, valueFunction);
}
return resolveGenericRank(rankData, valueFunction);
}
private String resolveModeRank(Matcher matcher, Function<Score, ?> valueFunction) {
try {
int rank = Integer.parseInt(matcher.group(2));
String name = matcher.group(1);
Leaderboard leaderboard = Modes.DEFAULT.getLeaderboard();
Mode mode = Registry.getMode(name);
if (mode != null) {
leaderboard = mode.getLeaderboard();
}
return resolveScore(leaderboard, rank, valueFunction);
} catch (NumberFormatException e) {
return "?";
}
}
private String resolveGenericRank(String rankData, Function<Score, ?> valueFunction) {
try {
int rank = Integer.parseInt(rankData);
return resolveScore(Modes.DEFAULT.getLeaderboard(), rank, valueFunction);
} catch (NumberFormatException e) {
return "?";
}
}
private String resolveScore(Leaderboard leaderboard, int rank, Function<Score, ?> valueFunction) {
if (rank <= 0) {
return "?";
}
Score score = leaderboard.getScoreAtRank(rank);
if (score == null) {
return "?";
}
return String.valueOf(valueFunction.apply(score));
}
}

View file

@ -1,20 +1,19 @@
package dev.loki.loparkour.leaderboard.core;
import dev.loki.loparkour.leaderboard.persistence.LeaderboardStorage;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.leaderboard.persistence.LeaderboardStorage;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Facade for leaderboard: CRUD operations with automatic sorting.
* Delegates to LeaderboardStorage and LeaderboardSorter.
* Delegates to LeaderboardStorage, LeaderboardSorter and LeaderboardRecords.
*/
public class Leaderboard {
@ -35,11 +34,13 @@ public class Leaderboard {
private final LeaderboardStorage storage;
private final LeaderboardSorter sorter;
private final LeaderboardRecords records;
public Leaderboard(@NotNull String mode, LeaderboardSorter.Sort sort) {
this.mode = mode.toLowerCase();
this.sort = sort;
this.sorter = new LeaderboardSorter(sort);
this.records = new LeaderboardRecords(scores, sorter);
this.storage = new LeaderboardStorage(mode, scores, sorter);
storage.initAndSchedule();
@ -81,27 +82,7 @@ public class Leaderboard {
*/
@Nullable
public Score put(@NotNull UUID uuid, @NotNull Score score) {
Score previous;
synchronized (scores) {
previous = scores.get(uuid);
if (previous != null && !isBetterThan(score, previous)) {
return previous; // keep the better score
}
scores.put(uuid, score);
}
sorter.sortInPlace(scores);
return previous;
}
/** Returns true if {@code candidate} is strictly better than {@code existing}. */
private static boolean isBetterThan(@NotNull Score candidate, @NotNull Score existing) {
if (candidate.score() != existing.score()) {
return candidate.score() > existing.score();
}
// same score — faster time wins
return candidate.getTimeMillis() < existing.getTimeMillis();
return records.put(uuid, score);
}
/**
@ -112,18 +93,14 @@ public class Leaderboard {
*/
@Nullable
public Score remove(@NotNull UUID uuid) {
return scores.remove(uuid);
return records.remove(uuid);
}
/**
* Resets all registered scores for this mode
*/
public void resetAll() {
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
keys.forEach(this::remove);
records.resetAll();
}
/**
@ -132,7 +109,7 @@ public class Leaderboard {
*/
@NotNull
public Score get(@NotNull UUID uuid) {
return scores.getOrDefault(uuid, new Score("?", "?", "?", 0));
return records.get(uuid);
}
/**
@ -140,11 +117,7 @@ public class Leaderboard {
* @return The rank. Starts from 1. Returns 0 if no ranking is found
*/
public int getRank(@NotNull UUID uuid) {
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
return keys.indexOf(uuid) + 1;
return records.getRank(uuid);
}
/**
@ -156,14 +129,7 @@ public class Leaderboard {
*/
@Nullable
public Score getScoreAtRank(int rank) {
List<Score> values;
synchronized (scores) {
if (scores.size() < rank) {
return null;
}
values = new ArrayList<>(scores.values());
}
return values.get(rank - 1);
return records.getScoreAtRank(rank);
}
// ── Sorting ───────────────────────────────────────────────────────────────

View file

@ -0,0 +1,120 @@
package dev.loki.loparkour.leaderboard.core;
import dev.loki.loparkour.leaderboard.model.Score;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Manages individual score entries for a leaderboard: add, update, remove and
* read operations. Keeps the backing map sorted after each mutation.
*/
class LeaderboardRecords {
private final Map<UUID, Score> scores;
private final LeaderboardSorter sorter;
LeaderboardRecords(@NotNull Map<UUID, Score> scores, @NotNull LeaderboardSorter sorter) {
this.scores = scores;
this.sorter = sorter;
}
/**
* Registers a new score — only replaces the old one if it's strictly better
* (higher score, or same score with faster time).
*
* @param uuid The player's uuid
* @param score The {@link Score} instance associated with a player's run
* @return the previous best score, if there was one
*/
@Nullable
Score put(@NotNull UUID uuid, @NotNull Score score) {
Score previous;
synchronized (scores) {
previous = scores.get(uuid);
if (previous != null && !isBetterThan(score, previous)) {
return previous;
}
scores.put(uuid, score);
}
sorter.sortInPlace(scores);
return previous;
}
/** Returns true if {@code candidate} is strictly better than {@code existing}. */
private static boolean isBetterThan(@NotNull Score candidate, @NotNull Score existing) {
if (candidate.score() != existing.score()) {
return candidate.score() > existing.score();
}
return candidate.getTimeMillis() < existing.getTimeMillis();
}
/**
* Resets the score of a player by deleting it from the internal map
*
* @param uuid The UUID
* @return the previous value if one was found
*/
@Nullable
Score remove(@NotNull UUID uuid) {
return scores.remove(uuid);
}
/**
* Resets all registered scores for this mode
*/
void resetAll() {
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
keys.forEach(this::remove);
}
/**
* @param uuid The {@link UUID} to get
* @return The {@link Score} associated with the player. If null, returns a {@link Score} instance with "?"
*/
@NotNull
Score get(@NotNull UUID uuid) {
return scores.getOrDefault(uuid, new Score("?", "?", "?", 0));
}
/**
* @param uuid The uuid
* @return The rank. Starts from 1. Returns 0 if no ranking is found
*/
int getRank(@NotNull UUID uuid) {
List<UUID> keys;
synchronized (scores) {
keys = new ArrayList<>(scores.keySet());
}
return keys.indexOf(uuid) + 1;
}
/**
* Gets the score at a specified rank.
* Ranks start at 1.
*
* @param rank The rank
* @return the {@link Score} instance, null if one isn't found
*/
@Nullable
Score getScoreAtRank(int rank) {
List<Score> values;
synchronized (scores) {
if (scores.size() < rank) {
return null;
}
values = new ArrayList<>(scores.values());
}
return values.get(rank - 1);
}
}

View file

@ -1,13 +1,13 @@
package dev.loki.loparkour.mode.base;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.mode.impl.CoopMode;
import dev.loki.loparkour.mode.impl.DefaultMode;
import dev.loki.loparkour.mode.impl.GravityShiftMode;
import dev.loki.loparkour.mode.impl.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.RaceMode;
import dev.loki.loparkour.mode.impl.SpectatorMode;
import dev.loki.loparkour.mode.impl.SpeedrunMode;
import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode;
import dev.loki.loparkour.mode.impl.multi.CoopMode;
import dev.loki.loparkour.mode.impl.multi.RaceMode;
import dev.loki.loparkour.mode.impl.multi.SpectatorMode;
import dev.loki.loparkour.mode.impl.solo.DefaultMode;
import dev.loki.loparkour.mode.impl.solo.GravityShiftMode;
import dev.loki.loparkour.mode.impl.solo.SpeedrunMode;
public final class Modes {

View file

@ -1,222 +0,0 @@
package dev.loki.loparkour.mode.impl;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.item.Item;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Random;
import java.util.Set;
public class InvisibleBarrierMode implements Mode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
@Override
@NotNull
public String getName() {
return "invisible-barrier";
}
@Override
@Nullable
public Item getItem(String locale) {
return Locales.getItem(locale, "play.single.invisible-barrier");
}
@Override
@NotNull
public Leaderboard getLeaderboard() {
return leaderboard;
}
@Override
public void create(Player player) {
if (!ModeMessages.checkJoiningEnabled(player)) {
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator instanceof BarrierGenerator) {
return;
}
player.closeInventory();
Session.create(session -> new BarrierGenerator(session), null, null, player);
}
static class BarrierGenerator extends ParkourGenerator {
private static final double RENDER_DISTANCE = 24.0;
private static final double PARTICLE_STEP = 0.1;
private final Set<Location> activeBarriers = new HashSet<>();
private final Random random = new Random();
private final Map<Location, Particle.DustOptions> blockColors = new HashMap<>();
private int particleTick = 0;
BarrierGenerator(@NotNull Session session) {
super(session);
}
@Override
public void generateFirst(@NotNull Location spawn, @NotNull Location block) {
super.generateFirst(spawn, block);
convertLastBlockToBarrier();
}
@Override
public void generate(int amount) {
super.generate(amount);
int start = Math.max(0, state.history.size() - amount);
for (int i = start; i < state.history.size(); i++) {
convertBlockToBarrier(state.history.get(i));
}
}
@Override
public void tick() {
super.tick();
particleTick++;
if (particleTick % 4 == 0) {
renderOutlines();
}
}
@Override
public void reset(boolean regenerate) {
activeBarriers.clear();
blockColors.clear();
particleTick = 0;
super.reset(regenerate);
}
@Override
public Mode getMode() {
return Modes.INVISIBLE_BARRIER;
}
private void convertLastBlockToBarrier() {
if (state.history.isEmpty()) {
return;
}
convertBlockToBarrier(state.history.get(state.history.size() - 1));
}
private void convertBlockToBarrier(@NotNull Block block) {
Location loc = block.getLocation();
if (block.getType() == Material.BARRIER) {
return;
}
block.setType(Material.BARRIER, false);
activeBarriers.add(loc);
Color color = Color.fromRGB(random.nextInt(256), random.nextInt(256), random.nextInt(256));
blockColors.put(loc, new Particle.DustOptions(color, 1.0f));
}
private void renderOutlines() {
if (activeBarriers.isEmpty()) {
return;
}
// Self-clean invalid entries
Iterator<Map.Entry<Location, Particle.DustOptions>> it = blockColors.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Location, Particle.DustOptions> entry = it.next();
if (entry.getKey().getBlock().getType() != Material.BARRIER) {
it.remove();
activeBarriers.remove(entry.getKey());
}
}
for (ParkourPlayer pp : getPlayers()) {
Player player = pp.player;
Location playerLoc = player.getLocation();
for (Location loc : activeBarriers) {
if (loc.getWorld() != playerLoc.getWorld()) {
continue;
}
if (loc.distanceSquared(playerLoc) > RENDER_DISTANCE * RENDER_DISTANCE) {
continue;
}
Particle.DustOptions options = blockColors.get(loc);
if (options == null) {
continue;
}
drawBlockOutline(player, loc, options);
}
}
}
private record Point(double x, double y, double z) {}
private void drawBlockOutline(@NotNull Player player, @NotNull Location loc, @NotNull Particle.DustOptions options) {
double x = loc.getX();
double y = loc.getY();
double z = loc.getZ();
Point p = new Point(x, y, z);
Point q = new Point(x + 1, y, z);
Point r = new Point(x + 1, y, z + 1);
Point s = new Point(x, y, z + 1);
// Bottom edges (y offset = 0)
drawEdge(player, p, q, options);
drawEdge(player, q, r, options);
drawEdge(player, r, s, options);
drawEdge(player, s, p, options);
// Top edges (y offset = 1)
Point pt = new Point(x, y + 1, z);
Point qt = new Point(x + 1, y + 1, z);
Point rt = new Point(x + 1, y + 1, z + 1);
Point st = new Point(x, y + 1, z + 1);
drawEdge(player, pt, qt, options);
drawEdge(player, qt, rt, options);
drawEdge(player, rt, st, options);
drawEdge(player, st, pt, options);
// Vertical edges
drawEdge(player, p, pt, options);
drawEdge(player, q, qt, options);
drawEdge(player, r, rt, options);
drawEdge(player, s, st, options);
}
private void drawEdge(@NotNull Player player, @NotNull Point start, @NotNull Point end,
@NotNull Particle.DustOptions options) {
double dx = end.x - start.x;
double dy = end.y - start.y;
double dz = end.z - start.z;
double length = Math.sqrt(dx * dx + dy * dy + dz * dz);
int steps = Math.max(1, (int) (length / PARTICLE_STEP));
for (int i = 0; i <= steps; i++) {
double t = (double) i / steps;
double px = start.x + dx * t;
double py = start.y + dy * t;
double pz = start.z + dz * t;
player.spawnParticle(Particle.REDSTONE, px, py, pz, 1, options);
}
}
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.loparkour.mode.impl.barrier;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.session.core.Session;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
class BarrierGenerator extends ParkourGenerator {
private final BarrierRenderer renderer = new BarrierRenderer();
BarrierGenerator(@NotNull Session session) {
super(session);
}
@Override
public void generateFirst(@NotNull Location spawn, @NotNull Location block) {
super.generateFirst(spawn, block);
renderer.convertLastBlockToBarrier(state);
}
@Override
public void generate(int amount) {
super.generate(amount);
int start = Math.max(0, state.history.size() - amount);
for (int i = start; i < state.history.size(); i++) {
renderer.convertBlockToBarrier(state.history.get(i));
}
}
@Override
public void tick() {
super.tick();
renderer.tick(getPlayers());
}
@Override
public void reset(boolean regenerate) {
renderer.reset();
super.reset(regenerate);
}
@Override
public Mode getMode() {
return Modes.INVISIBLE_BARRIER;
}
}

View file

@ -0,0 +1,153 @@
package dev.loki.loparkour.mode.impl.barrier;
import dev.loki.loparkour.generator.core.model.GeneratorState;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
class BarrierRenderer {
private static final double RENDER_DISTANCE = 24.0;
private static final double PARTICLE_STEP = 0.1;
private final Set<Location> activeBarriers = new HashSet<>();
private final Random random = new Random();
private final Map<Location, Particle.DustOptions> blockColors = new HashMap<>();
private int particleTick = 0;
void convertLastBlockToBarrier(@NotNull GeneratorState state) {
if (state.history.isEmpty()) {
return;
}
convertBlockToBarrier(state.history.get(state.history.size() - 1));
}
void convertBlockToBarrier(@NotNull Block block) {
Location loc = block.getLocation();
if (block.getType() == Material.BARRIER) {
return;
}
block.setType(Material.BARRIER, false);
activeBarriers.add(loc);
Color color = Color.fromRGB(random.nextInt(256), random.nextInt(256), random.nextInt(256));
blockColors.put(loc, new Particle.DustOptions(color, 1.0f));
}
void tick(@NotNull List<ParkourPlayer> players) {
particleTick++;
if (particleTick % 4 == 0) {
renderOutlines(players);
}
}
void reset() {
activeBarriers.clear();
blockColors.clear();
particleTick = 0;
}
private void renderOutlines(@NotNull List<ParkourPlayer> players) {
if (activeBarriers.isEmpty()) {
return;
}
purgeStaleBarriers();
for (ParkourPlayer pp : players) {
renderForPlayer(pp.player, pp.player.getLocation());
}
}
private void purgeStaleBarriers() {
Iterator<Map.Entry<Location, Particle.DustOptions>> it = blockColors.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Location, Particle.DustOptions> entry = it.next();
if (entry.getKey().getBlock().getType() != Material.BARRIER) {
it.remove();
activeBarriers.remove(entry.getKey());
}
}
}
private void renderForPlayer(@NotNull Player player, @NotNull Location playerLoc) {
for (Location loc : activeBarriers) {
if (!isVisible(playerLoc, loc)) {
continue;
}
Particle.DustOptions options = blockColors.get(loc);
if (options == null) {
continue;
}
drawBlockOutline(player, loc, options);
}
}
private boolean isVisible(@NotNull Location playerLoc, @NotNull Location loc) {
return loc.getWorld() == playerLoc.getWorld()
&& loc.distanceSquared(playerLoc) <= RENDER_DISTANCE * RENDER_DISTANCE;
}
private record Point(double x, double y, double z) {
}
private void drawBlockOutline(@NotNull Player player, @NotNull Location loc, @NotNull Particle.DustOptions options) {
double x = loc.getX();
double y = loc.getY();
double z = loc.getZ();
Point p = new Point(x, y, z);
Point q = new Point(x + 1, y, z);
Point r = new Point(x + 1, y, z + 1);
Point s = new Point(x, y, z + 1);
drawEdge(player, p, q, options);
drawEdge(player, q, r, options);
drawEdge(player, r, s, options);
drawEdge(player, s, p, options);
Point pt = new Point(x, y + 1, z);
Point qt = new Point(x + 1, y + 1, z);
Point rt = new Point(x + 1, y + 1, z + 1);
Point st = new Point(x, y + 1, z + 1);
drawEdge(player, pt, qt, options);
drawEdge(player, qt, rt, options);
drawEdge(player, rt, st, options);
drawEdge(player, st, pt, options);
drawEdge(player, p, pt, options);
drawEdge(player, q, qt, options);
drawEdge(player, r, rt, options);
drawEdge(player, s, st, options);
}
private void drawEdge(@NotNull Player player, @NotNull Point start, @NotNull Point end,
@NotNull Particle.DustOptions options) {
double dx = end.x - start.x;
double dy = end.y - start.y;
double dz = end.z - start.z;
double length = Math.sqrt(dx * dx + dy * dy + dz * dz);
int steps = Math.max(1, (int) (length / PARTICLE_STEP));
for (int i = 0; i <= steps; i++) {
double t = (double) i / steps;
double px = start.x + dx * t;
double py = start.y + dy * t;
double pz = start.z + dz * t;
player.spawnParticle(Particle.REDSTONE, px, py, pz, 1, options);
}
}
}

View file

@ -0,0 +1,50 @@
package dev.loki.loparkour.mode.impl.barrier;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.item.Item;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class InvisibleBarrierMode implements Mode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
@Override
@NotNull
public String getName() {
return "invisible-barrier";
}
@Override
@Nullable
public Item getItem(String locale) {
return Locales.getItem(locale, "play.single.invisible-barrier");
}
@Override
@NotNull
public Leaderboard getLeaderboard() {
return leaderboard;
}
@Override
public void create(Player player) {
if (!ModeMessages.checkJoiningEnabled(player)) {
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator instanceof BarrierGenerator) {
return;
}
player.closeInventory();
Session.create(session -> new BarrierGenerator(session), null, null, player);
}
}

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.multi;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.multi;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.multi;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.solo;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.solo;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.mode.impl;
package dev.loki.loparkour.mode.impl.solo;
import dev.loki.loparkour.mode.base.ModeMessages;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.mode.base.Modes;

View file

@ -2,19 +2,14 @@ package dev.loki.loparkour.player.core;
import com.google.gson.annotations.Expose;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.core.model.Profile;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.MultiMode;
import dev.loki.loparkour.player.data.PreviousData;
import dev.loki.loparkour.player.service.ParkourHotbar;
import dev.loki.loparkour.player.service.PlayerSettingsManager;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.world.core.Divider;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
@ -22,10 +17,8 @@ import org.bukkit.potion.PotionEffect;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
/**
@ -37,9 +30,6 @@ public class ParkourPlayer extends ParkourUser {
public static final Map<String, PlayerSettingsManager.OptionContainer> PLAYER_COLUMNS = PlayerSettingsManager.getColumnMappings();
// Track scored blocks to prevent duplicate scoring (String key = "x,y,z" to avoid Bukkit Block reference equality)
private final Set<String> scoredBlocks = new HashSet<>();
public @Expose Double schematicDifficulty;
public @Expose Integer blockLead;
public @Expose Boolean particles;
@ -51,6 +41,11 @@ public class ParkourPlayer extends ParkourUser {
public @Expose String style;
public @Expose String locale;
public @Expose List<String> collectedRewards;
private final PlayerScoringTracker scoring = new PlayerScoringTracker();
private final PlayerPersistence persistence = new PlayerPersistence();
private final PlayerSpawnSetup spawnSetup = new PlayerSpawnSetup();
/**
* Creates a new instance of a ParkourPlayer<br>
* If you are using the API, please use {@link ParkourPlayer#register(Player, Session)} instead
@ -107,36 +102,32 @@ public class ParkourPlayer extends ParkourUser {
}
session.removePlayers(this);
// Clear scored blocks to prevent memory leak
scoredBlocks.clear();
save(LoParkour.getPlugin().isEnabled());
// Clear scored blocks to prevent memory leak
scoring.clear();
persistence.save(this, LoParkour.getPlugin().isEnabled());
}
/**
* Clear scored blocks set, typically on fall/respawn so same coordinates can be re-scored.
*/
public void clearScoredBlocks() {
scoredBlocks.clear();
}
private static String blockKey(Block block) {
return block.getX() + "," + block.getY() + "," + block.getZ();
scoring.clear();
}
/**
* Check if player has already scored on this block.
*/
public boolean hasScored(Block block) {
return scoredBlocks.contains(blockKey(block));
return scoring.hasScored(block);
}
/**
* Mark block as scored to prevent duplicate scoring.
*/
public void markScored(Block block) {
scoredBlocks.add(blockKey(block));
scoring.markScored(block);
}
/**
@ -172,36 +163,20 @@ public class ParkourPlayer extends ParkourUser {
* Saves the player's data to their file
*/
public void save(boolean async) {
Runnable write = () -> Storage.writePlayer(this);
if (async) {
Scheduler.get(LoParkour.getPlugin()).runAsync(write);
} else {
write.run();
}
persistence.save(this, async);
}
public void setup(Location to) {
if (to != null) {
teleport(to);
}
player.setGameMode(GameMode.ADVENTURE);
if (Config.CONFIG.getBoolean("options.inventory-handling")) {
Scheduler.get(LoParkour.getPlugin()).runLater(() -> ParkourHotbar.apply(this), 5);
} else {
sendTranslated("other.customize");
}
spawnSetup.setup(this, to);
}
/**
* Updates hotbar items with current language
*/
public void updateHotbar() {
ParkourHotbar.apply(this);
spawnSetup.updateHotbar(this);
}
public record OptionContainer(ParkourOption option, BiConsumer<ParkourPlayer, String> consumer) {
}
}
}

View file

@ -0,0 +1,21 @@
package dev.loki.loparkour.player.core;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.storage.Storage;
import dev.lolib.scheduler.Scheduler;
/**
* Handles persistence of player data to the configured storage backend.
*/
final class PlayerPersistence {
void save(ParkourPlayer player, boolean async) {
Runnable write = () -> Storage.writePlayer(player);
if (async) {
Scheduler.get(LoParkour.getPlugin()).runAsync(write);
} else {
write.run();
}
}
}

View file

@ -0,0 +1,31 @@
package dev.loki.loparkour.player.core;
import org.bukkit.block.Block;
import java.util.HashSet;
import java.util.Set;
/**
* Tracks the blocks a player has already scored on to prevent duplicate scoring.
*/
final class PlayerScoringTracker {
// Track scored blocks to prevent duplicate scoring (String key = "x,y,z" to avoid Bukkit Block reference equality)
private final Set<String> scoredBlocks = new HashSet<>();
boolean hasScored(Block block) {
return scoredBlocks.contains(blockKey(block));
}
void markScored(Block block) {
scoredBlocks.add(blockKey(block));
}
void clear() {
scoredBlocks.clear();
}
private static String blockKey(Block block) {
return block.getX() + "," + block.getY() + "," + block.getZ();
}
}

View file

@ -0,0 +1,32 @@
package dev.loki.loparkour.player.core;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.player.service.ParkourHotbar;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.GameMode;
import org.bukkit.Location;
/**
* Prepares a parkour player's world state such as location, game mode and hotbar.
*/
final class PlayerSpawnSetup {
void setup(ParkourPlayer player, Location to) {
if (to != null) {
player.teleport(to);
}
player.player.setGameMode(GameMode.ADVENTURE);
if (Config.CONFIG.getBoolean("options.inventory-handling")) {
Scheduler.get(LoParkour.getPlugin()).runLater(() -> ParkourHotbar.apply(player), 5);
} else {
player.sendTranslated("other.customize");
}
}
void updateHotbar(ParkourPlayer player) {
ParkourHotbar.apply(player);
}
}

View file

@ -19,6 +19,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Level;
/**
* Loads {@code .nbt}, {@code .schem}, and {@code .schematic} files from {@code plugins/LoParkour/schematics/}.
@ -100,7 +101,7 @@ public final class SchematicManager {
ok++;
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe("Failed to load structure: " + file.getName());
e.printStackTrace();
LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", e);
}
}
return ok;
@ -126,7 +127,7 @@ public final class SchematicManager {
ok++;
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe("Failed to load schematic: " + file.getName());
e.printStackTrace();
LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", e);
}
}
return ok;

View file

@ -0,0 +1,63 @@
package dev.loki.loparkour.storage.sql;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.logging.Logger;
/**
* CRUD for player data.
*/
class SQLPlayerRepository {
private static final Logger LOGGER = Logger.getLogger(SQLPlayerRepository.class.getName());
private SQLPlayerRepository() {
}
static void readPlayer(@NotNull SQLConnectionManager manager, @NotNull ParkourPlayer player) {
if (!manager.isConnected()) {
return;
}
String query = SQLQueryBuilder.selectPlayer();
try (Connection conn = manager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, player.player.getUniqueId().toString());
try (ResultSet rs = stmt.executeQuery()) {
SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
if (data != null) {
SQLDataMapper.applyPlayerData(player, data);
}
}
} catch (SQLException e) {
LOGGER.severe("Failed to read player data: " + e.getMessage());
}
}
static void writePlayer(@NotNull SQLConnectionManager manager, @NotNull ParkourPlayer player) {
if (!manager.isConnected()) {
return;
}
String query = SQLQueryBuilder.upsertPlayer();
try (Connection conn = manager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
SQLDataMapper.setPlayerParameters(stmt, player);
stmt.executeUpdate();
} catch (SQLException e) {
LOGGER.severe("Failed to write player data: " + e.getMessage());
}
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.loparkour.storage.sql;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
/**
* Creates the database schema (DDL) for parkour storage.
*/
class SQLSchemaManager {
private static final String ADAPTIVE_STATS_TABLE = """
CREATE TABLE IF NOT EXISTS loparkour_player_stats (
player_uuid VARCHAR(36) PRIMARY KEY,
skill_rating DOUBLE DEFAULT 1.0,
sessions_count INT DEFAULT 0,
total_jumps INT DEFAULT 0,
total_falls INT DEFAULT 0,
longest_streak INT DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_skill_rating (skill_rating),
INDEX idx_last_updated (last_updated)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""";
private SQLSchemaManager() {
}
static void createTables(@NotNull SQLConnectionManager manager, @NotNull String mode) throws SQLException {
String tableName = SQLQueryBuilder.getTableName(mode);
try (Connection conn = manager.getConnection()) {
createScoresTable(conn, tableName);
createPlayersTable(conn);
createAdaptiveStatsTable(conn);
}
}
private static void createScoresTable(@NotNull Connection conn, @NotNull String tableName) throws SQLException {
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createScoresTable(tableName))) {
stmt.executeUpdate();
}
}
private static void createPlayersTable(@NotNull Connection conn) throws SQLException {
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createPlayersTable())) {
stmt.executeUpdate();
}
}
private static void createAdaptiveStatsTable(@NotNull Connection conn) throws SQLException {
try (PreparedStatement stmt = conn.prepareStatement(ADAPTIVE_STATS_TABLE)) {
stmt.executeUpdate();
}
}
}

View file

@ -0,0 +1,68 @@
package dev.loki.loparkour.storage.sql;
import dev.loki.loparkour.leaderboard.model.Score;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
/**
* CRUD for leaderboard scores.
*/
class SQLScoreRepository {
private static final Logger LOGGER = Logger.getLogger(SQLScoreRepository.class.getName());
private SQLScoreRepository() {
}
@NotNull
static Map<UUID, Score> readScores(@NotNull SQLConnectionManager manager, @NotNull String mode) {
if (!manager.isConnected()) {
return Map.of();
}
String query = SQLQueryBuilder.selectScores(SQLQueryBuilder.getTableName(mode));
try (Connection conn = manager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query);
ResultSet rs = stmt.executeQuery()) {
return SQLDataMapper.mapScores(rs);
} catch (SQLException e) {
LOGGER.severe("Failed to read scores for mode " + mode + ": " + e.getMessage());
return Map.of();
}
}
static void writeScores(@NotNull SQLConnectionManager manager,
@NotNull String mode,
@NotNull Map<UUID, Score> scores) {
if (!manager.isConnected() || scores.isEmpty()) {
return;
}
String query = SQLQueryBuilder.upsertScore(SQLQueryBuilder.getTableName(mode));
try (Connection conn = manager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
for (Map.Entry<UUID, Score> entry : scores.entrySet()) {
SQLDataMapper.setScoreParameters(stmt, entry.getValue(), entry.getKey());
stmt.addBatch();
}
stmt.executeBatch();
LOGGER.info("Wrote " + scores.size() + " scores for mode: " + mode);
} catch (SQLException e) {
LOGGER.severe("Failed to write scores for mode " + mode + ": " + e.getMessage());
}
}
}

View file

@ -4,17 +4,14 @@ import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
/**
* Refactored StorageSQL using composition pattern.
* Coordinates connection management, query building, and data mapping.
* Facade for SQL storage. Coordinates connection lifecycle and delegates
* CRUD to dedicated repository classes.
*/
public class StorageSQL {
@ -23,7 +20,7 @@ public class StorageSQL {
private static final Logger LOGGER = Logger.getLogger(StorageSQL.class.getName());
private static SQLConnectionManager connectionManager;
/**
* Check if database is connected.
*/
@ -38,7 +35,7 @@ public class StorageSQL {
}
return connectionManager;
}
/**
* Run callback when database is connected.
*/
@ -49,7 +46,7 @@ public class StorageSQL {
LOGGER.warning("Database not connected, skipping operation");
}
}
/**
* Close database connection.
*/
@ -59,7 +56,7 @@ public class StorageSQL {
connectionManager = null;
}
}
/**
* Initialize database connection and tables.
*/
@ -67,15 +64,15 @@ public class StorageSQL {
try {
connectionManager = new SQLConnectionManager();
connectionManager.connect();
createTables(mode);
SQLSchemaManager.createTables(connectionManager, mode);
LOGGER.info("Database initialized successfully for mode: " + mode);
} catch (SQLException e) {
LOGGER.severe("Failed to initialize database: " + e.getMessage());
}
}
/**
* Read scores for specific mode.
*/
@ -84,22 +81,9 @@ public class StorageSQL {
if (!isConnected()) {
return Map.of();
}
String tableName = SQLQueryBuilder.getTableName(mode);
String query = SQLQueryBuilder.selectScores(tableName);
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query);
ResultSet rs = stmt.executeQuery()) {
return SQLDataMapper.mapScores(rs);
} catch (SQLException e) {
LOGGER.severe("Failed to read scores for mode " + mode + ": " + e.getMessage());
return Map.of();
}
return SQLScoreRepository.readScores(connectionManager, mode);
}
/**
* Write scores for specific mode.
*/
@ -107,26 +91,9 @@ public class StorageSQL {
if (!isConnected() || scores.isEmpty()) {
return;
}
String tableName = SQLQueryBuilder.getTableName(mode);
String query = SQLQueryBuilder.upsertScore(tableName);
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
for (Map.Entry<UUID, Score> entry : scores.entrySet()) {
SQLDataMapper.setScoreParameters(stmt, entry.getValue(), entry.getKey());
stmt.addBatch();
}
stmt.executeBatch();
LOGGER.info("Wrote " + scores.size() + " scores for mode: " + mode);
} catch (SQLException e) {
LOGGER.severe("Failed to write scores for mode " + mode + ": " + e.getMessage());
}
SQLScoreRepository.writeScores(connectionManager, mode, scores);
}
/**
* Read player data from database.
*/
@ -134,26 +101,9 @@ public class StorageSQL {
if (!isConnected()) {
return;
}
String query = SQLQueryBuilder.selectPlayer();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, player.player.getUniqueId().toString());
try (ResultSet rs = stmt.executeQuery()) {
SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
if (data != null) {
SQLDataMapper.applyPlayerData(player, data);
}
}
} catch (SQLException e) {
LOGGER.severe("Failed to read player data: " + e.getMessage());
}
SQLPlayerRepository.readPlayer(connectionManager, player);
}
/**
* Write player data to database.
*/
@ -161,49 +111,6 @@ public class StorageSQL {
if (!isConnected()) {
return;
}
String query = SQLQueryBuilder.upsertPlayer();
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
SQLDataMapper.setPlayerParameters(stmt, player);
stmt.executeUpdate();
} catch (SQLException e) {
LOGGER.severe("Failed to write player data: " + e.getMessage());
}
SQLPlayerRepository.writePlayer(connectionManager, player);
}
private static void createTables(@NotNull String mode) throws SQLException {
String tableName = SQLQueryBuilder.getTableName(mode);
try (Connection conn = connectionManager.getConnection()) {
// Create scores table
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createScoresTable(tableName))) {
stmt.executeUpdate();
}
// Create players table
try (PreparedStatement stmt = conn.prepareStatement(SQLQueryBuilder.createPlayersTable())) {
stmt.executeUpdate();
}
// Adaptive player stats
try (PreparedStatement stmt = conn.prepareStatement(
"CREATE TABLE IF NOT EXISTS loparkour_player_stats ("
+ "player_uuid VARCHAR(36) PRIMARY KEY, "
+ "skill_rating DOUBLE DEFAULT 1.0, "
+ "sessions_count INT DEFAULT 0, "
+ "total_jumps INT DEFAULT 0, "
+ "total_falls INT DEFAULT 0, "
+ "longest_streak INT DEFAULT 0, "
+ "last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+ "INDEX idx_skill_rating (skill_rating), "
+ "INDEX idx_last_updated (last_updated)"
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")) {
stmt.executeUpdate();
}
}
}
}
}