diff --git a/.gitignore b/.gitignore index a51a626..47399f1 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ TODO.md # Compiled output bin/ + +# Reference material (local only) +ref/ diff --git a/TODO.md b/TODO.md index ca2e6d3..4e1f930 100644 --- a/TODO.md +++ b/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` + привязка через 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 или 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`, каждый `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 |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>`, каждый тик шлёт игроку `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, креш от битых схем. diff --git a/build.gradle.kts b/build.gradle.kts index 65d7121..34b0ef6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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().configureEach { @@ -184,5 +196,14 @@ tasks.withType().configureEach { } } +tasks.withType().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") diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index a3a9089..76fdca4 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -12,6 +12,19 @@ + + + + + + + + + + + @@ -85,5 +98,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml new file mode 100644 index 0000000..5fc5706 --- /dev/null +++ b/config/checkstyle/suppressions.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/pmd/ruleset.xml b/config/pmd/ruleset.xml new file mode 100644 index 0000000..8545040 --- /dev/null +++ b/config/pmd/ruleset.xml @@ -0,0 +1,46 @@ + + + + + + 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). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6872265 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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`, 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>` → `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`. + diff --git a/src/main/java/dev/loki/loparkour/LoParkour.java b/src/main/java/dev/loki/loparkour/LoParkour.java index 546e79d..3bdb560 100644 --- a/src/main/java/dev/loki/loparkour/LoParkour.java +++ b/src/main/java/dev/loki/loparkour/LoParkour.java @@ -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); } } } diff --git a/src/main/java/dev/loki/loparkour/adaptive/core/JumpTimingTracker.java b/src/main/java/dev/loki/loparkour/adaptive/core/JumpTimingTracker.java new file mode 100644 index 0000000..32cdf14 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/adaptive/core/JumpTimingTracker.java @@ -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 lastBlockCache; + private final Map 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); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/adaptive/core/MetricEventCollector.java b/src/main/java/dev/loki/loparkour/adaptive/core/MetricEventCollector.java new file mode 100644 index 0000000..b5085dc --- /dev/null +++ b/src/main/java/dev/loki/loparkour/adaptive/core/MetricEventCollector.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/adaptive/core/MetricsCacheStore.java b/src/main/java/dev/loki/loparkour/adaptive/core/MetricsCacheStore.java new file mode 100644 index 0000000..aee1c3f --- /dev/null +++ b/src/main/java/dev/loki/loparkour/adaptive/core/MetricsCacheStore.java @@ -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 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; + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/adaptive/core/MetricsCollector.java b/src/main/java/dev/loki/loparkour/adaptive/core/MetricsCollector.java index 4658897..795d450 100644 --- a/src/main/java/dev/loki/loparkour/adaptive/core/MetricsCollector.java +++ b/src/main/java/dev/loki/loparkour/adaptive/core/MetricsCollector.java @@ -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 metricsCache; - private final Map lastBlockCache; - private final Map 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(); } } diff --git a/src/main/java/dev/loki/loparkour/adaptive/storage/SQLStatsStorage.java b/src/main/java/dev/loki/loparkour/adaptive/storage/SQLStatsStorage.java index d714da0..ff6e07c 100644 --- a/src/main/java/dev/loki/loparkour/adaptive/storage/SQLStatsStorage.java +++ b/src/main/java/dev/loki/loparkour/adaptive/storage/SQLStatsStorage.java @@ -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()); diff --git a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java index 5ff95e4..aa62589 100644 --- a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java +++ b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java @@ -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; diff --git a/src/main/java/dev/loki/loparkour/command/LoParkourCommand.java b/src/main/java/dev/loki/loparkour/command/LoParkourCommand.java deleted file mode 100644 index c4f4815..0000000 --- a/src/main/java/dev/loki/loparkour/command/LoParkourCommand.java +++ /dev/null @@ -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 SCHEMATIC_DIFFICULTIES = List.of("0.0", "0.25", "0.5", "0.75", "1.0"); - private static final List 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 onTabComplete(@NotNull CommandSender sender, @NotNull Command command, - @NotNull String alias, @NotNull String[] args) { - List 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 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 filter(String input, List 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)); - } -} diff --git a/src/main/java/dev/loki/loparkour/command/admin/AdminCommandHandler.java b/src/main/java/dev/loki/loparkour/command/admin/AdminCommandHandler.java index d14609f..6165a04 100644 --- a/src/main/java/dev/loki/loparkour/command/admin/AdminCommandHandler.java +++ b/src/main/java/dev/loki/loparkour/command/admin/AdminCommandHandler.java @@ -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)); - } } diff --git a/src/main/java/dev/loki/loparkour/command/admin/AdminResponses.java b/src/main/java/dev/loki/loparkour/command/admin/AdminResponses.java new file mode 100644 index 0000000..eaf64be --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/admin/AdminResponses.java @@ -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)); + } +} diff --git a/src/main/java/dev/loki/loparkour/command/admin/AdminTargetResolver.java b/src/main/java/dev/loki/loparkour/command/admin/AdminTargetResolver.java new file mode 100644 index 0000000..a24a5ae --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/admin/AdminTargetResolver.java @@ -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; + } +} diff --git a/src/main/java/dev/loki/loparkour/command/admin/ForceJoinExecutor.java b/src/main/java/dev/loki/loparkour/command/admin/ForceJoinExecutor.java new file mode 100644 index 0000000..15073c8 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/admin/ForceJoinExecutor.java @@ -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); + } +} diff --git a/src/main/java/dev/loki/loparkour/command/admin/ForceLeaveExecutor.java b/src/main/java/dev/loki/loparkour/command/admin/ForceLeaveExecutor.java new file mode 100644 index 0000000..55b153d --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/admin/ForceLeaveExecutor.java @@ -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); + } +} diff --git a/src/main/java/dev/loki/loparkour/command/admin/RecoverInventoryExecutor.java b/src/main/java/dev/loki/loparkour/command/admin/RecoverInventoryExecutor.java new file mode 100644 index 0000000..895069d --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/admin/RecoverInventoryExecutor.java @@ -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())); + } + }); + } +} diff --git a/src/main/java/dev/loki/loparkour/command/admin/ResetExecutor.java b/src/main/java/dev/loki/loparkour/command/admin/ResetExecutor.java new file mode 100644 index 0000000..63344b5 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/admin/ResetExecutor.java @@ -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 action) { + Registry.getModes().stream() + .map(Mode::getLeaderboard) + .filter(lb -> lb != null) + .forEach(action); + } +} diff --git a/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java b/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java new file mode 100644 index 0000000..aae2ee1 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java b/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java new file mode 100644 index 0000000..7ffdefa --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java @@ -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 SCHEMATIC_DIFFICULTIES = List.of("0.0", "0.25", "0.5", "0.75", "1.0"); + private static final List SCHEMATIC_SUBCOMMANDS = List.of( + "wand", "pos1", "pos2", "create", "convert", "paste", "list", "reload" + ); + + private CommandTabCompleter() { + } + + static List 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 completeFirst(CommandSender sender, String input) { + List 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 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 completeReset(CommandSender sender, String input) { + List 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 completeJoin(CommandSender sender, String input) { + List 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 completeLeaderboard(CommandSender sender, String input) { + List completions = new ArrayList<>(); + if (ParkourOption.LEADERBOARDS.mayPerform(sender)) { + Registry.getModes().forEach(m -> completions.add(m.getName())); + } + return filter(input, completions); + } + + private static List completeSchematic(CommandSender sender, String input) { + List completions = new ArrayList<>(); + if (sender.hasPermission(ParkourOption.ADMIN.permission)) { + completions.addAll(SCHEMATIC_SUBCOMMANDS); + } + return filter(input, completions); + } + + private static List completeCreate(CommandSender sender, String input) { + List completions = new ArrayList<>(); + if (sender.hasPermission(ParkourOption.ADMIN.permission)) { + completions.addAll(SCHEMATIC_DIFFICULTIES); + } + return filter(input, completions); + } + + private static List completeForce(CommandSender sender, String a1, String input) { + List 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 completeRecoverInventory(CommandSender sender, String input) { + List completions = new ArrayList<>(); + if (sender.hasPermission(ParkourOption.ADMIN.permission)) { + Bukkit.getOnlinePlayers().forEach(pl -> completions.add(pl.getName())); + } + return filter(input, completions); + } + + private static List completeThird(String a1Raw, String a2Raw, String input) { + if (!"schematic".equalsIgnoreCase(a1Raw)) { + return Collections.emptyList(); + } + return completeSchematicThird(a2Raw, input); + } + + private static List completeSchematicThird(String a2Raw, String input) { + List 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 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 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 filter(String input, List options) { + String lower = input.toLowerCase(); + return options.stream().filter(s -> s.toLowerCase().startsWith(lower)).toList(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/core/LoParkourCommand.java b/src/main/java/dev/loki/loparkour/command/core/LoParkourCommand.java new file mode 100644 index 0000000..d6d8dc7 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/core/LoParkourCommand.java @@ -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 onTabComplete(@NotNull CommandSender sender, @NotNull Command command, + @NotNull String alias, @NotNull String[] args) { + return CommandTabCompleter.complete(sender, args); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/player/JoinCommandExecutor.java b/src/main/java/dev/loki/loparkour/command/player/JoinCommandExecutor.java new file mode 100644 index 0000000..182a3eb --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/player/JoinCommandExecutor.java @@ -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); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/player/LeaderboardCommandExecutor.java b/src/main/java/dev/loki/loparkour/command/player/LeaderboardCommandExecutor.java new file mode 100644 index 0000000..dc33254 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/player/LeaderboardCommandExecutor.java @@ -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); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/schematic/SchematicCommandHandler.java b/src/main/java/dev/loki/loparkour/command/schematic/SchematicCommandHandler.java index b24442f..240ca7d 100644 --- a/src/main/java/dev/loki/loparkour/command/schematic/SchematicCommandHandler.java +++ b/src/main/java/dev/loki/loparkour/command/schematic/SchematicCommandHandler.java @@ -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 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 } */ @@ -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 } or {@code schematic create } */ @@ -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)); - } -} +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/schematic/SchematicDispatcher.java b/src/main/java/dev/loki/loparkour/command/schematic/SchematicDispatcher.java new file mode 100644 index 0000000..e413ce1 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/schematic/SchematicDispatcher.java @@ -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 -> { + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/schematic/SchematicHandler.java b/src/main/java/dev/loki/loparkour/command/schematic/SchematicHandler.java new file mode 100644 index 0000000..13ac6cb --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/schematic/SchematicHandler.java @@ -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_-]+$"); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/schematic/SchematicMessenger.java b/src/main/java/dev/loki/loparkour/command/schematic/SchematicMessenger.java new file mode 100644 index 0000000..127649e --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/schematic/SchematicMessenger.java @@ -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, ""); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/schematic/SchematicWand.java b/src/main/java/dev/loki/loparkour/command/schematic/SchematicWand.java new file mode 100644 index 0000000..8c0abea --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/schematic/SchematicWand.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/schematic/SelectionStore.java b/src/main/java/dev/loki/loparkour/command/schematic/SelectionStore.java new file mode 100644 index 0000000..019d559 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/schematic/SelectionStore.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/command/util/CommandUtil.java b/src/main/java/dev/loki/loparkour/command/util/CommandUtil.java new file mode 100644 index 0000000..91822ac --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/util/CommandUtil.java @@ -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)); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/config/core/ConfigLoader.java b/src/main/java/dev/loki/loparkour/config/core/ConfigLoader.java index f833ea3..c7840f9 100644 --- a/src/main/java/dev/loki/loparkour/config/core/ConfigLoader.java +++ b/src/main/java/dev/loki/loparkour/config/core/ConfigLoader.java @@ -35,14 +35,13 @@ public class ConfigLoader { } public static void updateConfig(@NotNull String fileName, @NotNull File configFile, @Nullable List 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); } } diff --git a/src/main/java/dev/loki/loparkour/config/core/ConfigMergeParser.java b/src/main/java/dev/loki/loparkour/config/core/ConfigMergeParser.java new file mode 100644 index 0000000..ec1c5e4 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/config/core/ConfigMergeParser.java @@ -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 readLines(@NotNull InputStream is) throws IOException { + try (var r = new BufferedReader(new InputStreamReader(is))) { + return r.lines().toList(); + } + } + + @NotNull + List readLines(@NotNull File file) throws IOException { + return Files.readAllLines(file.toPath()); + } + + @NotNull + Map extractFullPathValues(@NotNull List lines) { + Map values = new LinkedHashMap<>(); + Deque 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 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 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) { + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/config/core/ConfigMergeProcessor.java b/src/main/java/dev/loki/loparkour/config/core/ConfigMergeProcessor.java new file mode 100644 index 0000000..b701699 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/config/core/ConfigMergeProcessor.java @@ -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 merge( + @NotNull InputStream templateStream, + @NotNull File existingFile, + @Nullable List ignoredKeys) throws IOException { + + List templateLines = parser.readLines(templateStream); + List existingLines = existingFile.exists() ? parser.readLines(existingFile) : List.of(); + + Set ignored = normalizeIgnored(ignoredKeys); + Map existingValues = parser.extractFullPathValues(existingLines); + + List result = new ArrayList<>(); + Deque 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 existingValues) { + + String existing = existingValues.get(fullPath); + if (existing == null) { + return line; + } + return line.substring(0, line.indexOf(':') + 1) + " " + existing; + } + + @NotNull + private static Set normalizeIgnored(@Nullable List keys) { + if (keys == null) { + return Set.of(); + } + Set s = new HashSet<>(); + for (String k : keys) { + s.add(k.trim().toLowerCase()); + } + return s; + } + + private static boolean isIgnored(@NotNull String fullPath, @NotNull Set ignored) { + String lower = fullPath.toLowerCase(); + for (String ign : ignored) { + if (lower.equals(ign) || lower.startsWith(ign + ".")) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/config/core/ConfigUpdater.java b/src/main/java/dev/loki/loparkour/config/core/ConfigUpdater.java index 18a5436..1a28fca 100644 --- a/src/main/java/dev/loki/loparkour/config/core/ConfigUpdater.java +++ b/src/main/java/dev/loki/loparkour/config/core/ConfigUpdater.java @@ -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 merged = merge(templateStream, configFile, ignoredKeys); + List 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 merge( - @NotNull InputStream templateStream, - @NotNull File existingFile, - @Nullable List ignoredKeys) throws IOException { - - List templateLines = readLines(templateStream); - List existingLines = existingFile.exists() ? readLines(existingFile) : List.of(); - - Set ignored = normalizeIgnored(ignoredKeys); - Map existingValues = extractFullPathValues(existingLines); - - List result = new ArrayList<>(); - Deque stack = new ArrayDeque<>(); - int prevIndent = -1; - - for (String line : templateLines) { - String trimmed = line.trim(); - - if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) { - result.add(line); - continue; - } - - 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 extractFullPathValues(@NotNull List lines) { - Map values = new LinkedHashMap<>(); - Deque 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 readLines(@NotNull InputStream is) throws IOException { - try (var r = new BufferedReader(new InputStreamReader(is))) { - return r.lines().toList(); - } - } - - @NotNull - private static List readLines(@NotNull File file) throws IOException { - return Files.readAllLines(file.toPath()); - } - - @NotNull - private static Set normalizeIgnored(@Nullable List keys) { - if (keys == null) { - return Set.of(); - } - Set s = new HashSet<>(); - for (String k : keys) { - s.add(k.trim().toLowerCase()); - } - return s; - } - - private static boolean isIgnored(@NotNull String fullPath, @NotNull Set ignored) { - String lower = fullPath.toLowerCase(); - for (String ign : ignored) { - if (lower.equals(ign) || lower.startsWith(ign + ".")) { - return true; - } - } - return false; - } -} +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockPlacer.java b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockPlacer.java index ce5f8f8..299e7e4 100644 --- a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockPlacer.java +++ b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockPlacer.java @@ -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 selectBlocks(int forcedHeight) { - List 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 blocks = tall ? selectBlocks(-1) : selectBlocks(); + boolean tall = targetSelector.isTallMaterial(blockData.getMaterial()); + List 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 blocks = tall ? selectBlocks(-1) : selectBlocks(); + boolean tall = targetSelector.isTallMaterial(specialBlockData.getMaterial()); + List 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 selectBlocks() { - List 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 blocks = selectBlocks(); - if (blocks.isEmpty()) { - return false; - } - - Location pasteLocation = blocks.get(0).getLocation(); - List 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; - } -} +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockTargetSelector.java b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockTargetSelector.java new file mode 100644 index 0000000..5a3ccdf --- /dev/null +++ b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockTargetSelector.java @@ -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 selectBlocks() { + return selectBlocks(NO_FORCED_HEIGHT); + } + + @NotNull + List selectBlocks(int forcedHeight) { + List 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; + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockWorldPlacer.java b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockWorldPlacer.java new file mode 100644 index 0000000..fe352f1 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockWorldPlacer.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/generator/jump/placement/SchematicBlockPlacer.java b/src/main/java/dev/loki/loparkour/generator/jump/placement/SchematicBlockPlacer.java new file mode 100644 index 0000000..6d71f77 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/generator/jump/placement/SchematicBlockPlacer.java @@ -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 blocks = targetSelector.selectBlocks(); + if (blocks.isEmpty()) { + return false; + } + + Location pasteLocation = blocks.get(0).getLocation(); + List 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; + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/hook/papi/PAPIHook.java b/src/main/java/dev/loki/loparkour/hook/papi/PAPIHook.java index be0c489..5dea620 100644 --- a/src/main/java/dev/loki/loparkour/hook/papi/PAPIHook.java +++ b/src/main/java/dev/loki/loparkour/hook/papi/PAPIHook.java @@ -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 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 "?"; - } - } -} +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/hook/papi/PlaceholderDispatcher.java b/src/main/java/dev/loki/loparkour/hook/papi/PlaceholderDispatcher.java new file mode 100644 index 0000000..2837944 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/hook/papi/PlaceholderDispatcher.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/hook/papi/resolver/GlobalPlaceholderResolver.java b/src/main/java/dev/loki/loparkour/hook/papi/resolver/GlobalPlaceholderResolver.java new file mode 100644 index 0000000..02da7cb --- /dev/null +++ b/src/main/java/dev/loki/loparkour/hook/papi/resolver/GlobalPlaceholderResolver.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/hook/papi/resolver/PlaceholderFormatters.java b/src/main/java/dev/loki/loparkour/hook/papi/resolver/PlaceholderFormatters.java new file mode 100644 index 0000000..7258e30 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/hook/papi/resolver/PlaceholderFormatters.java @@ -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); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/hook/papi/resolver/PlayerPlaceholderResolver.java b/src/main/java/dev/loki/loparkour/hook/papi/resolver/PlayerPlaceholderResolver.java new file mode 100644 index 0000000..38f8b71 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/hook/papi/resolver/PlayerPlaceholderResolver.java @@ -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"; + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/hook/papi/resolver/ScoreRankResolver.java b/src/main/java/dev/loki/loparkour/hook/papi/resolver/ScoreRankResolver.java new file mode 100644 index 0000000..40b2858 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/hook/papi/resolver/ScoreRankResolver.java @@ -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 valueFunction) { + Matcher matcher = INFINITE_REGEX.matcher(rankData); + if (matcher.matches()) { + return resolveModeRank(matcher, valueFunction); + } + return resolveGenericRank(rankData, valueFunction); + } + + private String resolveModeRank(Matcher matcher, Function 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 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 valueFunction) { + if (rank <= 0) { + return "?"; + } + Score score = leaderboard.getScoreAtRank(rank); + if (score == null) { + return "?"; + } + return String.valueOf(valueFunction.apply(score)); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/leaderboard/core/Leaderboard.java b/src/main/java/dev/loki/loparkour/leaderboard/core/Leaderboard.java index 79a741b..7e5653d 100644 --- a/src/main/java/dev/loki/loparkour/leaderboard/core/Leaderboard.java +++ b/src/main/java/dev/loki/loparkour/leaderboard/core/Leaderboard.java @@ -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 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 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 values; - synchronized (scores) { - if (scores.size() < rank) { - return null; - } - values = new ArrayList<>(scores.values()); - } - return values.get(rank - 1); + return records.getScoreAtRank(rank); } // ── Sorting ─────────────────────────────────────────────────────────────── diff --git a/src/main/java/dev/loki/loparkour/leaderboard/core/LeaderboardRecords.java b/src/main/java/dev/loki/loparkour/leaderboard/core/LeaderboardRecords.java new file mode 100644 index 0000000..1ddf8de --- /dev/null +++ b/src/main/java/dev/loki/loparkour/leaderboard/core/LeaderboardRecords.java @@ -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 scores; + private final LeaderboardSorter sorter; + + LeaderboardRecords(@NotNull Map 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 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 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 values; + synchronized (scores) { + if (scores.size() < rank) { + return null; + } + values = new ArrayList<>(scores.values()); + } + return values.get(rank - 1); + } +} diff --git a/src/main/java/dev/loki/loparkour/mode/base/Modes.java b/src/main/java/dev/loki/loparkour/mode/base/Modes.java index 5a7fa58..56088a3 100644 --- a/src/main/java/dev/loki/loparkour/mode/base/Modes.java +++ b/src/main/java/dev/loki/loparkour/mode/base/Modes.java @@ -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 { diff --git a/src/main/java/dev/loki/loparkour/mode/impl/InvisibleBarrierMode.java b/src/main/java/dev/loki/loparkour/mode/impl/InvisibleBarrierMode.java deleted file mode 100644 index 8a511b0..0000000 --- a/src/main/java/dev/loki/loparkour/mode/impl/InvisibleBarrierMode.java +++ /dev/null @@ -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 activeBarriers = new HashSet<>(); - private final Random random = new Random(); - private final Map 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> it = blockColors.entrySet().iterator(); - while (it.hasNext()) { - Map.Entry 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); - } - } - } -} diff --git a/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierGenerator.java b/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierGenerator.java new file mode 100644 index 0000000..e507a32 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierGenerator.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierRenderer.java b/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierRenderer.java new file mode 100644 index 0000000..5fa3809 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierRenderer.java @@ -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 activeBarriers = new HashSet<>(); + private final Random random = new Random(); + private final Map 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 players) { + particleTick++; + if (particleTick % 4 == 0) { + renderOutlines(players); + } + } + + void reset() { + activeBarriers.clear(); + blockColors.clear(); + particleTick = 0; + } + + private void renderOutlines(@NotNull List players) { + if (activeBarriers.isEmpty()) { + return; + } + + purgeStaleBarriers(); + + for (ParkourPlayer pp : players) { + renderForPlayer(pp.player, pp.player.getLocation()); + } + } + + private void purgeStaleBarriers() { + Iterator> it = blockColors.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry 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); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/mode/impl/barrier/InvisibleBarrierMode.java b/src/main/java/dev/loki/loparkour/mode/impl/barrier/InvisibleBarrierMode.java new file mode 100644 index 0000000..78fecf5 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/mode/impl/barrier/InvisibleBarrierMode.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/mode/impl/CoopMode.java b/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopMode.java similarity index 99% rename from src/main/java/dev/loki/loparkour/mode/impl/CoopMode.java rename to src/main/java/dev/loki/loparkour/mode/impl/multi/CoopMode.java index 20a25c6..a15d76f 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/CoopMode.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopMode.java @@ -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; diff --git a/src/main/java/dev/loki/loparkour/mode/impl/RaceMode.java b/src/main/java/dev/loki/loparkour/mode/impl/multi/RaceMode.java similarity index 99% rename from src/main/java/dev/loki/loparkour/mode/impl/RaceMode.java rename to src/main/java/dev/loki/loparkour/mode/impl/multi/RaceMode.java index 969dd61..4c6b9a6 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/RaceMode.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/multi/RaceMode.java @@ -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; diff --git a/src/main/java/dev/loki/loparkour/mode/impl/SpectatorMode.java b/src/main/java/dev/loki/loparkour/mode/impl/multi/SpectatorMode.java similarity index 97% rename from src/main/java/dev/loki/loparkour/mode/impl/SpectatorMode.java rename to src/main/java/dev/loki/loparkour/mode/impl/multi/SpectatorMode.java index fdf5c05..ed2b78f 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/SpectatorMode.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/multi/SpectatorMode.java @@ -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; diff --git a/src/main/java/dev/loki/loparkour/mode/impl/DefaultMode.java b/src/main/java/dev/loki/loparkour/mode/impl/solo/DefaultMode.java similarity index 97% rename from src/main/java/dev/loki/loparkour/mode/impl/DefaultMode.java rename to src/main/java/dev/loki/loparkour/mode/impl/solo/DefaultMode.java index 9f6b3d2..9cdf241 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/DefaultMode.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/solo/DefaultMode.java @@ -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; diff --git a/src/main/java/dev/loki/loparkour/mode/impl/GravityShiftMode.java b/src/main/java/dev/loki/loparkour/mode/impl/solo/GravityShiftMode.java similarity index 99% rename from src/main/java/dev/loki/loparkour/mode/impl/GravityShiftMode.java rename to src/main/java/dev/loki/loparkour/mode/impl/solo/GravityShiftMode.java index 3f56546..7155874 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/GravityShiftMode.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/solo/GravityShiftMode.java @@ -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; diff --git a/src/main/java/dev/loki/loparkour/mode/impl/SpeedrunMode.java b/src/main/java/dev/loki/loparkour/mode/impl/solo/SpeedrunMode.java similarity index 99% rename from src/main/java/dev/loki/loparkour/mode/impl/SpeedrunMode.java rename to src/main/java/dev/loki/loparkour/mode/impl/solo/SpeedrunMode.java index 7fd9f5e..9e3e91c 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/SpeedrunMode.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/solo/SpeedrunMode.java @@ -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; diff --git a/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java b/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java index 90004f4..30083a9 100644 --- a/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java +++ b/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java @@ -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 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 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 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
* 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 consumer) { } -} +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/player/core/PlayerPersistence.java b/src/main/java/dev/loki/loparkour/player/core/PlayerPersistence.java new file mode 100644 index 0000000..a222953 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/player/core/PlayerPersistence.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/player/core/PlayerScoringTracker.java b/src/main/java/dev/loki/loparkour/player/core/PlayerScoringTracker.java new file mode 100644 index 0000000..5d94ff7 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/player/core/PlayerScoringTracker.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/player/core/PlayerSpawnSetup.java b/src/main/java/dev/loki/loparkour/player/core/PlayerSpawnSetup.java new file mode 100644 index 0000000..4fdbf65 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/player/core/PlayerSpawnSetup.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java b/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java index 46d1857..1ee5446 100644 --- a/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java +++ b/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java @@ -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; diff --git a/src/main/java/dev/loki/loparkour/storage/sql/SQLPlayerRepository.java b/src/main/java/dev/loki/loparkour/storage/sql/SQLPlayerRepository.java new file mode 100644 index 0000000..26f92b2 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/storage/sql/SQLPlayerRepository.java @@ -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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/storage/sql/SQLSchemaManager.java b/src/main/java/dev/loki/loparkour/storage/sql/SQLSchemaManager.java new file mode 100644 index 0000000..b64c9c8 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/storage/sql/SQLSchemaManager.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/storage/sql/SQLScoreRepository.java b/src/main/java/dev/loki/loparkour/storage/sql/SQLScoreRepository.java new file mode 100644 index 0000000..f70a866 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/storage/sql/SQLScoreRepository.java @@ -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 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 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 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/storage/sql/StorageSQL.java b/src/main/java/dev/loki/loparkour/storage/sql/StorageSQL.java index 4517354..2d03736 100644 --- a/src/main/java/dev/loki/loparkour/storage/sql/StorageSQL.java +++ b/src/main/java/dev/loki/loparkour/storage/sql/StorageSQL.java @@ -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 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(); - } - } - } -} +} \ No newline at end of file