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:
parent
1d57972038
commit
81788e71ed
70 changed files with 3428 additions and 1701 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -72,3 +72,6 @@ TODO.md
|
|||
|
||||
# Compiled output
|
||||
bin/
|
||||
|
||||
# Reference material (local only)
|
||||
ref/
|
||||
|
|
|
|||
260
TODO.md
260
TODO.md
|
|
@ -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, креш от битых схем.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
81
config/checkstyle/suppressions.xml
Normal file
81
config/checkstyle/suppressions.xml
Normal 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
46
config/pmd/ruleset.xml
Normal 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
250
docs/ARCHITECTURE.md
Normal 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`.
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
111
src/main/java/dev/loki/loparkour/command/core/CommandRouter.java
Normal file
111
src/main/java/dev/loki/loparkour/command/core/CommandRouter.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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_-]+$");
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 "?";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue