refactor: reorganize packages and replace LPSchematic with WorldEdit

Restructure core modules into subpackages (generator, schematic, config, session, etc.); add .schem/.nbt schematic pipeline, commands, adaptive stats skeleton, and bootstrap wiring.
This commit is contained in:
loki5512344 2026-06-01 14:02:48 +02:00
parent 6ba6e5137d
commit fd0ba2cc0e
203 changed files with 5652 additions and 2367 deletions

2
.gitignore vendored
View file

@ -31,7 +31,7 @@ hs_err_pid*
.settings/
.classpath
.project
.cursor/
# Maven
target/
pom.xml.tag

468
TODO.md
View file

@ -1,254 +1,283 @@
# LoParkour — TODO (обновлено 2026-04-27)
# LoParkour — TODO (обновлено 2026-06-01)
> Roadmap для превращения плагина в платформу с адаптивной генерацией.
> Roadmap + **честный аудит** репозитория. Старые галочки «✅» в фазах 1–4 не соответствуют коду — см. раздел **Аудит**.
## Легенда
- 🔴 Критично — блокирует работу
- 🟡 Важно — заявленная функциональность
- 🟢 Планово — улучшение
- ✅ Готово
- 🔴 Критично — блокирует сборку / релиз / основной UX
- 🟡 Важно — заявленная функциональность или i18n
- 🟢 Планово — улучшение, рефакторинг
- ✅ Готово (проверено в коде)
---
## Фаза 1: Рефакторинг (2-3 дня)
## Аудит 2026-06-01 (снимок)
### 🟢 Архитектура
| Область | Факт |
|--------|------|
| **Сборка** | ✅ `./gradlew compileJava` проходит |
| **Адаптивность** | 🟡 Подключено: `AdaptiveServices`, `MetricsCollector` на генераторе, профиль после fall/reset |
| **Elytra** | 🟡 `ElytraMode` + `ElytraGenerator` есть; отдельного `mode/elytra/*` из старого TODO **нет** |
| **Размер файлов** | 🔴 Цель «все < 200 строк» **не выполнена**: 18 файлов > 150, 2 > 200 |
| **i18n** | 🟡 Основные потоки через `Locales`; остаётся Phase 0 split + `MessageKeys` |
| **GUI** | 🟡 Основные меню на LoLib OK; **заглушки** `Menu`/`PagedMenu`, `DynamicMenu` вырезан |
| **TODO.md** | Ранее помечено ✅ то, что не сделано — статусы ниже исправлены |
| # | Задача | Файлы | Статус |
|---|--------|-------|--------|
| 1 | Разбить ParkourGenerator (335 строк) | Generator.java, GeneratorConfig.java | 🟢 |
| 2 | Разбить GeneratorProfileManager | ProfileManager.java, ProfileCalculator.java | 🟢 |
| 3 | Вынести statistics из generator | statistics/TimeTracker.java, ScoreTracker.java, LeaderboardSync.java | 🟢 |
| 4 | Создать структуру пакетов | generator/core/, generator/profile/, mode/base/, mode/impl/ | 🟢 |
| 5 | Рефакторинг интерфейсов | GameMode, DifficultyProfile, GeneratorEventListener | 🟢 |
### 🟢 Большие файлы (>200 строк)
| # | Файл | Строк | Действие | Статус |
|---|------|-------|----------|--------|
| 1 | Option.java | 335 | Разбить по типам опций | 🟢 |
| 2 | ElytraGenerator.java | 261 | Вынести в mode/elytra/ | 🟢 |
| 3 | Locales.java | 260 | Разбить на LocaleLoader + LocaleCache | 🟢 |
| 4 | ParkourPlayer.java | 236 | Вынести inventory/settings в отдельные классы | 🟢 |
| 5 | Leaderboard.java | 234 | Разбить на Leaderboard + LeaderboardStorage | 🟢 |
**Статистика:** ~160 `.java` файлов, средний размер ~85 строк, максимум **252** (`ElytraGenerator.java`).
---
## Фаза 2: Адаптивная система (3-4 дня)
## Фаза 0: Полный рефакторинг (KISS · DRY · SOLID) — приоритет
### 🟡 Инфраструктура
> Цель: предсказуемая структура, тонкие классы, без «божественных» файлов.
| # | Задача | Пакет | Статус |
|---|--------|-------|--------|
| 1 | Создать модели данных | adaptive/model/ | 🟡 |
| 2 | Реализовать хранилища | adaptive/storage/ | 🟡 |
| 3 | SQL таблицы для метрик | loparkour_player_stats | 🟡 |
| 4 | Файловое хранилище | playerdata/<uuid>.json | 🟡 |
### Правила (жёсткие)
### 🟡 Сбор метрик
| Правило | Значение |
|--------|----------|
| **Целевой размер** | ~**150** строк на файл |
| **Жёсткий потолок** | **190–200** строк (исключения только с комментом в PR) |
| **Файлов в папке** | ≤ **6** (при росте — новая подпапка) |
| **KISS** | Один публичный сценарий = один класс / use-case |
| **DRY** | Сообщения только через `Locales`; команды — `Messages` / `admin.*` |
| **SOLID** | `generator` не импортирует `adaptive`; связь только через `GeneratorEventListener` + DI в bootstrap |
| # | Задача | Класс | Статус |
|---|--------|-------|--------|
| 1 | MetricsCollector | adaptive/core/MetricsCollector.java | 🟡 |
| 2 | GeneratorEventListener | generator/core/GeneratorEventListener.java | 🟡 |
| 3 | Интеграция с Generator | Generator.score(), Generator.fall() | 🟡 |
| 4 | Детекция nearMiss | MetricsCollector.onBlockLand() | 🟡 |
### 🟡 Анализ и корректировка
| # | Задача | Класс | Статус |
|---|--------|-------|--------|
| 1 | SkillAnalyzer | adaptive/core/SkillAnalyzer.java | 🟡 |
| 2 | DifficultyCalculator | adaptive/core/DifficultyCalculator.java | 🟡 |
| 3 | DifficultyAdjuster | adaptive/core/DifficultyAdjuster.java | 🟡 |
| 4 | Формулы рейтинга | SkillRating calculation | 🟡 |
### 🟡 Интеграция с режимами
| # | Задача | Класс | Статус |
|---|--------|-------|--------|
| 1 | AdaptiveMode базовый класс | mode/base/AdaptiveMode.java | 🟡 |
| 2 | AdaptiveSession | adaptive/core/AdaptiveSession.java | 🟡 |
| 3 | StatsService | adaptive/core/StatsService.java | 🟡 |
| 4 | Миграция DefaultMode | mode/impl/DefaultMode.java | 🟡 |
| 5 | Миграция SpeedrunMode | mode/impl/SpeedrunMode.java, SpeedrunGenerator.java | 🟡 |
| 6 | Миграция HardcoreMode | mode/impl/HardcoreMode.java | 🟡 |
| 7 | Миграция GravityShiftMode | mode/impl/GravityShiftMode.java | 🟡 |
### 🟡 Конфигурация
| # | Задача | Файл | Статус |
|---|--------|------|--------|
| 1 | Секция adaptive в config.yml | config.yml | 🟡 |
| 2 | Конфиги режимов | modes/*.yml | 🟡 |
| 3 | AdaptiveConfig с кэшированием | adaptive/model/AdaptiveConfig.java | 🟡 |
| 4 | Предзагрузка при старте | LoParkour.onEnable() | 🟡 |
---
## Фаза 3: ElytraMode (1-2 дня)
### 🔴 Критичные классы (отсутствуют)
| # | Класс | Ответственность | Статус |
|---|-------|-----------------|--------|
| 1 | ElytraConfig | Загрузка настроек из modes/elytra.yml | 🔴 |
| 2 | ElytraRing | Модель кольца (центр, радиус, направление) | 🔴 |
| 3 | RingGenerator | Генерация колец по траектории | 🔴 |
| 4 | RingRenderer | Отрисовка частицами | 🔴 |
| 5 | ElytraPhysics | Проверка пролёта, буст от фейерверков | 🔴 |
### 🔴 Интеграция
| # | Задача | Статус |
|---|--------|--------|
| 1 | Создать ElytraMode | 🔴 |
| 2 | Выдача элитры и фейерверков | 🔴 |
| 3 | Тесты полёта | 🔴 |
| 4 | Конфиг modes/elytra.yml | 🔴 |
---
## Фаза 4: Новые режимы (2-3 дня)
### 🟢 RoguelikeMode
| # | Задача | Статус |
|---|--------|--------|
| 1 | Случайные модификаторы каждые N блоков | 🟢 |
| 2 | Выбор перков перед стартом | 🟢 |
| 3 | Риск-награда развилки | 🟢 |
| 4 | Балансировка | 🟢 |
### 🟢 Проверка существующих режимов
| # | Режим | Задача | Статус |
|---|-------|--------|--------|
| 1 | RaceMode | Проверить прогресс-бар и финиш | 🟢 |
| 2 | CoopMode | Проверить мультиплеер | 🟢 |
| 3 | DefaultMode | Тесты после рефакторинга | 🟢 |
| 4 | SpeedrunMode | Тесты таймеров блоков | 🟢 |
---
## Архитектурные решения
### Структура пакетов
### Целевая структура пакетов (✅ 2026-06-01)
```
src/main/java/dev/loki/loparkour/
├── adaptive/ # Адаптивная система
│ ├── core/ # MetricsCollector, SkillAnalyzer, DifficultyCalculator, DifficultyAdjuster
│ ├── model/ # PlayerMetrics, SkillRating, AdaptiveConfig
│ └── storage/ # StatsRepository, SQLStatsStorage, FileStatsStorage
├── statistics/ # Вынесено из generator
│ ├── TimeTracker.java
│ ├── ScoreTracker.java
│ └── LeaderboardSync.java
dev.loki.loparkour/
├── LoParkour.java # main plugin class
├── command/
│ ├── LoParkourCommand.java
│ ├── player/ # PlayerCommandHandler
│ ├── admin/ # AdminCommandHandler
│ └── schematic/ # SchematicCommandHandler
├── config/
│ ├── core/ # Config, ConfigLoader, ConfigAccessor, ConfigUpdater
│ ├── locale/ # Locales, LocaleLoader, LocaleCache
│ └── options/ # Option + OptionSQL, OptionGeneral, …
├── generator/
│ ├── core/ # Generator, GeneratorState, GeneratorConfig, Island
│ ├── profile/ # DifficultyProfile, ProfileManager, ProfileCalculator
│ ├── jump/ # JumpCalculator, JumpValidator, JumpDirector, BlockPlacer
│ └── lifecycle/ # GeneratorLifecycle, GeneratorCleanup
└── mode/
├── base/ # GameMode, AdaptiveMode, ModeRegistry
├── impl/ # DefaultMode, SpeedrunMode, HardcoreMode, GravityShiftMode, RoguelikeMode
└── elytra/ # ElytraMode, ElytraConfig, ElytraRing, RingGenerator, RingRenderer, ElytraPhysics
│ ├── core/ # ParkourGenerator, Profile, GeneratorState, …
│ ├── jump/, lifecycle/, profile/, effect/
│ └── core/GeneratorEventListener
├── mode/
│ ├── base/ # Mode, MultiMode, Modes, ModeMessages
│ ├── impl/ # DefaultMode, SpeedrunMode, …
│ └── elytra/ # ElytraGenerator + ring/physics/hud
├── menu/
│ ├── core/ # LPMenu, MainMenu, Menus, ParkourOption
│ └── play/, settings/, community/, lobby/
├── player/
│ ├── core/ # ParkourUser, ParkourPlayer
│ ├── service/ # UserRegistry, hotbar, scoreboard, …
│ ├── spectator/
│ └── data/
├── util/
│ ├── text/, item/, gui/, world/, particle/, misc/
├── storage/
│ ├── Storage.java # фасад
│ ├── sql/ # StorageSQL, SQL*, миграции
│ └── disk/ # StorageDisk (JSON)
├── session/
│ ├── core/Session.java
│ └── manager/ # State, Player, User managers
├── listener/
│ ├── player/, gameplay/, schematic/
├── leaderboard/
│ ├── model/Score, core/, persistence/
├── ghost/
│ ├── model/, core/
├── hook/
│ ├── papi/, holo/, floodgate/, vault/
├── schematic/
│ ├── registry/Schematics.java
│ └── lpschem/
├── api/core/ + api/event/
├── reward/core/, style/core/, world/core/
├── adaptive/bootstrap/AdaptiveServices
├── bootstrap/PluginBootstrap
└── …
```
### Зависимости
Скрипт переноса: `scripts/refactor-packages.ps1` (+ `fix-imports-after-refactor.ps1`).
```
adaptive → generator/profile (только через DifficultyProfile интерфейс)
generator → НЕ знает про adaptive (через GeneratorEventListener)
statistics → используется и generator, и adaptive
mode → использует generator + опционально adaptive
```
### Очередь разбиения (файлы &gt; 150 строк)
### Хранение данных
| Приоритет | Файл | Строк | Действие |
|-----------|------|-------|----------|
| ✅ 1 | `ElytraGenerator.java` | 101 | → `elytra/ElytraRingLoop`, `ElytraScoring`, `ElytraHud`, `ElytraPlayerKit`, `ElytraFireworkListener` |
| ✅ 2 | `ParkourPlayer.java` | 166 | → `ParkourHotbar` |
| 🟡 3 | `PAPIHook.java` | 194 | → placeholders по группам |
| 🟡 4 | `LoParkour.java` | 192 | → `bootstrap/PluginBootstrap.java` |
| 🟡 5 | `MetricsCollector.java` | 187 | → session hook + jump hook + flush |
| 🟡 6 | `StorageSQL.java` | 179 | → queries / mapper / connection (уже частично) |
| 🟡 7 | `BlockPlacer.java` | 176 | | |
| 🟡 8 | `JumpValidator.java` | 172 | | |
| 🟡 9 | `LoParkourCommand.java` | 170 | → делегаты только маршрутизация |
| 🟡 10 | `CoopMode.java` | 165 | → `CoopGenerator` отдельный файл |
| 🟢 | остальные 151–159 | 8 файлов | после топ-10 |
**SQL (критичные метрики):**
- `loparkour_player_stats`: skill_rating, sessions_count, total_jumps, longest_streak
- Автосохранение каждые 5 минут
### Чек-лист фазы 0
**JSON (детальная аналитика):**
- `playerdata/<uuid>.json`: avgTimePerBlock, jumpTypeStats, nearMissCount
- `playerdata/history/<uuid>_jumps.jsonl`: история прыжков (последние 1000)
- Сохранение при выходе игрока
- [ ] Нет файлов &gt; 200 строк (кроме явно согласованных)
- [x] Пакеты разнесены (config/mode/command/generator/menu/player/util)
- [ ] Нет циклических зависимостей `generator` ↔ `adaptive`
- [ ] Единый `MessageService` / `Locales` для player + console + action bar + title
- [ ] Удалить или подключить мёртвый код: `Menu`, `PagedMenu`, `DynamicMenu`, `MenuStub`
- [ ] `Option.java` — только фасад, без роста (делегаты в `options/*`)
- [x] `./gradlew build` зелёный
- [ ] README/TODO не врут про «всё готово»
---
## Технический долг
## Баги и долг (найдено при аудите)
### 🟢 Оптимизация
### 🔴 Сборка
| # | Задача | Приоритет | Статус |
|---|--------|-----------|--------|
| 1 | Кэширование конфигов режимов | Средний | ✅ (в дизайне) |
| 2 | Batch updates для SQL метрик | Средний | 🟢 |
| 3 | Очистка старой истории прыжков | Низкий | 🟢 |
| # | Проблема | Статус |
|---|----------|--------|
| B1 | `SQLConnectionManager` visibility | ✅ |
| B2 | `AdaptiveConfig` + `Config.CONFIG` | ✅ |
### 🟢 Тестирование
### 🟡 Локализация (i18n) — большинство закрыто
| # | Проблема | Статус |
|---|----------|--------|
| I1 | `LPMenu` back/next | ✅ `other.back`, `other.next` |
| I2 | `other.no_menu_items` | ✅ |
| I3 | joining disabled | ✅ `ModeMessages` + `other.joining_disabled` |
| I4 | Elytra messages | ✅ `modes.elytra.*` |
| I5–I11 | Race, coop, admin, schematic, commands, bungee, GUI stubs | ✅ (см. `locales/*.yml`) |
| I12 | `ghost.saved` в en | ✅ |
| I13 | `%[a-z]` vs `%s` | 🟢 документировать / унифицировать позже |
| I14 | reload NPE с консоли | ✅ `getString(sender, …)` |
| I15 | недостающие ключи | ✅ основной набор добавлен |
### 🟡 Логика / архитектура
| # | Проблема | Где |
|---|----------|-----|
| A1 | `MetricsCollector` на генераторе | ✅ `SessionStateManager` + `AdaptiveServices.attachToGenerator` |
| A2 | Adaptive → профиль генерации | ✅ `GeneratorProfileManager` + `AdaptiveDifficulty` |
| A3 | `adaptive.*` в config.yml | ✅ |
| A4 | SQL таблица stats | ✅ `StorageSQL` + `SQLMigrationManager.createAdaptiveStatsTable` |
| A5 | `DynamicMenu` мёртвый | 🔴 |
| A6 | STONE debug-spam | ✅ только при отсутствии `.material` |
| A7 | Несогласованные пути locale: `play.single.*` vs `modes.*` для режимов | `*Mode.getItem()` |
| A8 | `sendHelp` упоминает `/LoParkour`, игроки используют `/parkour` | `PlayerCommandHandler` |
### 🟢 Мелочи
| # | Проблема |
|---|----------|
| M1 | `SpectatorMode`: MiniMessage `<red>` vs legacy `§c` в других режимах |
| M2 | `HardcoreMode` / др.: смешение `ColorUtil` и сырого `sendMessage` |
| M3 | Unit/integration тесты в TODO помечены ✅ — **фактически** только 2 test-класса |
---
## Фаза 1: Рефакторинг структуры (статус реальный)
### Архитектура
| # | Задача | Статус |
|---|--------|--------|
| 1 | Unit тесты для адаптивной системы | 🟢 |
| 2 | Integration тесты для режимов | 🟢 |
| 3 | Performance тесты (100+ игроков) | 🟢 |
| 1 | Разбить `ParkourGenerator` | 🟡 Частично (composition), файл 159 строк — ок |
| 2 | `GeneratorProfileManager` | 🟡 Есть, без отдельного `ProfileCalculator` |
| 3 | statistics из generator | 🟡 `GeneratorStatistics` внутри generator, не пакет `statistics/` |
| 4 | Структура пакетов `generator/core`, `jump`, `lifecycle` | 🟡 Частично |
| 5 | `GeneratorEventListener` | ✅ Интерфейс + вызовы в `PlayerInteractionHandler` |
### Большие файлы (цель &lt; 200) — **не выполнено**
| Файл | Строк | Статус |
|------|-------|--------|
| ElytraGenerator.java | 101 | ✅ |
| ParkourPlayer.java | 166 | ✅ |
| PAPIHook, LoParkour, MetricsCollector, … | 151–194 | 🟡 см. Фаза 0 |
---
## Чек-лист перед релизом
## Фаза 2: Адаптивная система (статус реальный)
- [ ] Все режимы работают (default, speedrun, hardcore, gravity-shift, elytra)
- [ ] Адаптивная система корректно собирает метрики
- [ ] SQL миграции применены
- [ ] Конфиги валидируются при загрузке
- [ ] Нет memory leaks (проверить с VisualVM)
- [ ] Документация обновлена (README.md, CHANGELOG.md)
- [ ] Тесты проходят (`./gradlew test`)
- [ ] Build успешен (`./gradlew build`)
| # | Задача | Статус |
|---|--------|--------|
| 1 | `adaptive/model/` | 🟡 Классы есть |
| 2 | `adaptive/storage/` | ✅ compile + SQL/file repo |
| 3 | SQL таблицы | ✅ CREATE в `StorageSQL` / `SQLMigrationManager` |
| 4 | File JSON storage | ✅ |
| 5 | `MetricsCollector` | ✅ |
| 6 | Calculator / Adjuster в профиле | ✅ через `AdaptiveDifficulty` |
| 7 | `AdaptiveMode`, `StatsService` | 🔴 отдельный режим не нужен пока |
| 8 | `config.yml` → `adaptive.*` | ✅ |
---
## Оценка времени
## Фаза 3: ElytraMode (статус реальный)
| Фаза | Задач | Дней | Начало | Конец |
|------|-------|------|--------|-------|
| Фаза 1: Рефакторинг | 10 | 2-3 | День 1 | День 3 |
| Фаза 2: Адаптивность | 20 | 3-4 | День 4 | День 7 |
| Фаза 3: ElytraMode | 9 | 1-2 | День 8 | День 9 |
| Фаза 4: Новые режимы | 8 | 2-3 | День 10 | День 12 |
| **Итого** | **47** | **8-12** | - | - |
| # | Задача | Статус |
|---|--------|--------|
| 1 | `ElytraMode` | 🟡 Есть |
| 2 | `ElytraGenerator` + helpers | ✅ Разбит на `mode/elytra/*` |
| 3 | `ElytraConfig`, `RingRenderer`, … | ✅ В пакете `mode/elytra/` |
| 4 | `modes/elytra.yml` | 🔴 Нет |
| 5 | i18n для elytra | ✅ |
---
## Метрики успеха
## Фаза 4: Режимы (статус реальный)
### Технические
- ✅ Все файлы < 200 строк
- ✅ Максимум 6 файлов в папке
- ✅ SOLID принципы соблюдены
- ✅ Нет циклических зависимостей
### Функциональные
- ✅ Адаптивная сложность работает для 4+ режимов
- ✅ Метрики сохраняются между сессиями
- ✅ ElytraMode полностью функционален
- ✅ Новый режим (Roguelike) добавлен
### Производительность
- ✅ TPS не падает при 50+ игроках
- ✅ SQL запросы < 50ms
- ✅ Конфиги кэшируются (нет I/O при входе игрока)
| Режим | Меню | i18n join disabled | Примечание |
|-------|------|-------------------|------------|
| default, speedrun, hardcore, gravity-shift | ✅ SingleMenu | 🔴 хардкод EN | |
| elytra, race | ✅ | 🔴 | race — ещё UI хардкод |
| coop | ❌ (null item — ок) | 🔴 | title хардкод |
| spectator | ✅ | 🟡 частично Locales | |
| roguelike | — | — | **Нет в коде** (в старом TODO было ✅ — ошибка) |
---
## SQL миграция
## Фаза 5: i18n — единая система сообщений
| # | Задача | Статус |
|---|--------|--------|
| 1 | `other.back` / `other.next` | ✅ |
| 2 | Ключи из таблицы I* | ✅ |
| 3 | `MessageKeys` enum или constants class (DRY путей) | 🟢 |
| 4 | `Locales.getString(CommandSender)` — использовать везде для консоли | 🟡 Частично |
| 5 | Убрать debug-spam STONE | ✅ |
| 6 | Синхронизировать `en.yml` ↔ `ru.yml` | 🟡 периодический diff |
| 7 | Проверка CI: скрипт diff ключей locale vs `Locales.getString`/`sendTranslated` | 🟢 |
---
## Технический долг (актуальный)
| # | Задача | Приоритет |
|---|--------|-----------|
| 1 | Compile adaptive | ✅ |
| 2 | Bootstrap MetricsCollector | ✅ |
| 3 | SQL adaptive table | ✅ |
| 4 | Удалить или реализовать `Menu`/`PagedMenu`/`DynamicMenu` | 🟡 |
| 5 | Реальные тесты adaptive + modes | 🟢 |
---
## Чек-лист перед релизом (честный)
- [x] `./gradlew build` без ошибок
- [x] Нет хардкода RU в `LPMenu` / основных режимах и админке
- [ ] Полный diff ключей locale vs Java (CI)
- [ ] Адаптивность: ручной тест на сервере (`adaptive.enabled: true`)
- [ ] ElytraMode протестирован вручную
- [ ] Нет файлов &gt; 200 строк (или исключения задокументированы)
- [ ] README / CHANGELOG соответствуют коду
---
## SQL миграция (без изменений)
```sql
-- Создание таблицы для адаптивных метрик
CREATE TABLE IF NOT EXISTS loparkour_player_stats (
player_uuid VARCHAR(36) PRIMARY KEY,
skill_rating DOUBLE DEFAULT 1.0,
@ -260,11 +289,36 @@ CREATE TABLE IF NOT EXISTS loparkour_player_stats (
INDEX idx_skill_rating (skill_rating),
INDEX idx_last_updated (last_updated)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Миграция существующих данных (если есть старая таблица)
INSERT INTO loparkour_player_stats (player_uuid, sessions_count, last_updated)
SELECT player_uuid, COUNT(*) as sessions, MAX(timestamp) as last_updated
FROM loparkour_scores
GROUP BY player_uuid
ON DUPLICATE KEY UPDATE sessions_count = VALUES(sessions_count);
```
---
## Оценка времени (пересмотр)
| Фаза | Дней | Комментарий |
|------|------|-------------|
| **0: KISS/DRY/SOLID + лимит строк** | 5–8 | Блокирует нормальную поддержку |
| **5: i18n** | 2–3 | Можно параллельно с 0 |
| **2: Adaptive** | 3–4 | После зелёной сборки + bootstrap |
| **3: Elytra split** | 1–2 | После 0 |
| **4: Режимы / тесты** | 2–3 | |
| **Итого** | **13–20** | Старые «8–12 дней» занижены |
---
## Метрики успеха (пересмотр)
### Технические
- [ ] ≥ 90% файлов ≤ 150 строк
- [ ] 0 файлов &gt; 200 строк
- [ ] SOLID: `adaptive` → listener/port, не прямой import generator internals
- [ ] `./gradlew test` — осмысленное покрытие, не «галочка в TODO»
### Функциональные
- [ ] 2+ языка без хардкода в Java (кроме логов debug)
- [ ] Adaptive влияет на профиль после N сессий
- [ ] ElytraMode без монолита 250+ строк
### Производительность
- [ ] TPS стабилен 50+ игроков (профилирование)
- [ ] SQL batch / flush adaptive &lt; 50ms

View file

@ -66,6 +66,11 @@ repositories {
name = "codemc-snapshots"
url = uri("https://repo.codemc.io/repository/maven-snapshots/")
}
maven {
name = "enginehub"
url = uri("https://maven.enginehub.org/repo/")
}
}
dependencies {
@ -85,7 +90,9 @@ dependencies {
implementation("org.bstats:bstats-bukkit:3.2.1")
implementation("com.zaxxer:HikariCP:5.1.0")
implementation("com.mysql:mysql-connector-j:8.3.0")
implementation("com.sk89q.worldedit:worldedit-core:7.3.6")
implementation("com.sk89q.worldedit:worldedit-bukkit:7.3.6")
// Provided dependencies (plugins)
compileOnly("me.clip:placeholderapi:2.11.6")
compileOnly("com.github.MilkBowl:VaultAPI:1.7")
@ -112,6 +119,8 @@ tasks.shadowJar {
relocate("io.papermc.lib", "dev.loki.loparkour.lib.paperlib")
relocate("com.google.gson", "dev.loki.loparkour.lib.gson")
relocate("org.bstats", "dev.loki.loparkour.lib.bstats")
relocate("com.sk89q.worldedit", "dev.loki.loparkour.lib.worldedit")
relocate("org.enginehub", "dev.loki.loparkour.lib.enginehub")
// Don't minimize - causes issues with Caffeine's dynamically generated classes
// minimize {

79
docs/SCHEMATICS.md Normal file
View file

@ -0,0 +1,79 @@
# Parkour schematics (LoParkour)
Jump structures are loaded from `plugins/LoParkour/schematics/` and matched to a **difficulty** in `plugins/LoParkour/schematics/schematics.yml`.
## Supported file types
| Extension | Source | Notes |
|-----------|--------|--------|
| `.nbt` | Vanilla structure block, `/structure save` | Pasted with Bukkit `StructureManager` |
| `.schem`, `.schematic` | WorldEdit (`//copy`, `//schem save`) | Read via embedded WorldEdit clipboard IO |
| `.lpschem` | **Legacy LoParkour format** | Not used in-game; convert with `/lp schematic convert` |
During parkour generation, when the profile rolls **schematic** type, the plugin picks a random file whose `difficulty` in YAML is **≤** the player's schematic setting (0.0–1.0).
## Difficulty keys (`schematics.yml`)
```yaml
difficulty:
d0e227d2: 0.25 # short id (recommended)
```
For file `parkour-d0e227d2.schem` you can use key `d0e227d2` or the full stem `parkour-d0e227d2`.
Player setting **0.25 (easy)** → only structures with difficulty ≤ 0.25.
**1.0** → any structure.
## Creating schematics in-game (admin)
1. Get the wand: `/lp schematic wand`
2. **Left-click** = position 1, **right-click** = position 2 (or use `/lp schematic pos1` / `pos2`)
3. Save and register:
- `/lp create <difficulty>` — auto name `parkour-<hash>.schem` + YAML entry
- `/lp schematic create <difficulty>` — same
- `/lp schematic create <name> <difficulty>` — custom name (letters, numbers, `-`, `_`)
Difficulty examples: `0.25`, `0.5`, `0.75`, `1.0` (must be between 0 and 1).
4. `/lp schematic reload` — reload files without restart
## Converting old `.lpschem` files
Place `.lpschem` files in `plugins/LoParkour/schematics/` or legacy `schematics-new/`, then:
```
/lp schematic convert
```
Each file becomes `parkour-<id>.schem` in `schematics/` and gets a `difficulty:` entry (from the old metadata, or `0.5` if missing). Original `.lpschem` files are kept; delete them manually after checking.
## Admin commands
| Command | Description |
|---------|-------------|
| `/lp schematic wand` | Selection wand |
| `/lp schematic pos1` / `pos2` | Set corners at your feet |
| `/lp create <difficulty>` | Save selection → `.schem` + YAML |
| `/lp schematic create <name> <difficulty>` | Save with custom name |
| `/lp schematic convert` | Convert all `.lpschem` in schematics folders |
| `/lp schematic list` | List loaded structures |
| `/lp schematic paste <name>` | Test-paste at your location |
| `/lp schematic reload` | Reload disk files |
## Flow (runtime)
```mermaid
flowchart LR
A[BlockPlacer SCHEMATIC roll] --> B[SchematicManager.pick]
B --> C{Format}
C -->|nbt| D[Structure.place]
C -->|schem| E[Clipboard paste]
D --> F[Track blocks / score]
E --> F
```
## Folders
- `plugins/LoParkour/schematics/` — `.nbt`, `.schem`, `.schematic` (active)
- `plugins/LoParkour/schematics/schematics.yml` — difficulty map
- `plugins/LoParkour/schematics-new/` — legacy; scanned only for `.lpschem` convert

View file

@ -1,34 +1,19 @@
package dev.loki.loparkour;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.hook.HoloHook;
import dev.loki.loparkour.hook.PAPIHook;
import dev.loki.loparkour.mode.DefaultMode;
import dev.loki.loparkour.mode.ElytraMode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.mode.SpectatorMode;
import dev.loki.loparkour.mode.SpeedrunMode;
import dev.loki.loparkour.mode.RaceMode;
import dev.loki.loparkour.mode.CoopMode;
import dev.loki.loparkour.mode.GravityShiftMode;
import dev.loki.loparkour.mode.HardcoreMode;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.player.UserRegistry;
import dev.loki.loparkour.reward.Rewards;
import dev.loki.loparkour.schematic.lpschem.LPSchematicManager;
import dev.loki.loparkour.bootstrap.PluginBootstrap;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.hook.papi.PAPIHook;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.reward.core.Rewards;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.world.Divider;
import dev.loki.loparkour.world.World;
import dev.loki.loparkour.world.core.Divider;
import dev.loki.loparkour.world.core.World;
import dev.lolib.core.LoPlugin;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.bstats.bukkit.Metrics;
import org.bstats.charts.SimplePie;
import org.bstats.charts.SingleLineChart;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
@ -41,45 +26,34 @@ import java.util.ArrayList;
*/
public final class LoParkour extends LoPlugin {
public static final String NAME = "&#FF6464<bold>LoParkour<reset>";
public static final String NAME = "&#FF6464&lLoParkour&r";
public static final String PREFIX = NAME + " &#404040» &#A0A0A0";
private static final Gson gson = new GsonBuilder()
.setPrettyPrinting()
.excludeFieldsWithoutExposeAnnotation()
.create();
public static Gson getGson() {
return gson;
}
private static LoParkour instance;
private static LPSchematicManager schematicManager;
private static SchematicManager schematicManager;
@Nullable
private static PAPIHook placeholderHook;
public static Gson getGson() {
return gson;
}
public static void log(String message) {
if (Config.CONFIG.getBoolean("debug")) {
LoParkour.getPlugin().getLogger().info("[Debug] " + message);
}
}
/**
* @param child The file name.
* @return A file from within the plugin folder.
*/
public static File getInFolder(String child) {
return new File(instance.getDataFolder(), child);
}
/**
* @return This plugin's {@link Logging} instance.
*/
/**
* @return The plugin instance.
*/
public static LoParkour getPlugin() {
return instance;
}
@ -89,104 +63,32 @@ public final class LoParkour extends LoPlugin {
return placeholderHook;
}
public static LPSchematicManager getSchematicManager() {
public static SchematicManager getSchematicManager() {
return schematicManager;
}
public void setSchematicManager(@NotNull SchematicManager manager) {
schematicManager = manager;
}
public void setPlaceholderHook(@Nullable PAPIHook hook) {
placeholderHook = hook;
}
@Override
public void enable() {
instance = this; // must be first — Config enum accesses instance inside reload()
loadConfigs();
loadSchematics();
registerModes();
registerHooks();
setupWorld();
registerEventsAndCommands();
setupMetrics();
}
private void loadConfigs() {
Config.reload(true);
}
private void loadSchematics() {
schematicManager = new LPSchematicManager();
schematicManager.loadAll();
}
private void registerModes() {
Registry.register(new DefaultMode());
Registry.register(new SpectatorMode());
Registry.register(new SpeedrunMode());
Registry.register(new ElytraMode());
Registry.register(new RaceMode());
Registry.register(new CoopMode());
Registry.register(new GravityShiftMode());
Registry.register(new HardcoreMode());
Modes.init();
}
private void registerHooks() {
if (getServer().getPluginManager().isPluginEnabled("HolographicDisplays")) {
getLogger().info("Registered Holographic Displays hook");
HoloHook.init();
}
if (getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) {
getLogger().info("Registered PlaceholderAPI hook");
placeholderHook = new PAPIHook();
placeholderHook.register();
}
if (Config.CONFIG.getBoolean("bungeecord.enabled")) {
getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord");
getLogger().info("Registered BungeeCord hook");
}
}
private void setupWorld() {
if (!Config.CONFIG.getBoolean("joining")) {
return;
}
// World creation MUST happen on the main thread (Bukkit API requirement)
try {
loLogger().info("Creating parkour world...");
World.create();
loLogger().info("Parkour world created successfully!");
} catch (Exception ex) {
loLogger().error("Failed to create parkour world", ex);
}
}
private void registerEventsAndCommands() {
dev.lolib.gui.GUIManager.init(this); // required for LoLib GUI click handling
var pm = getServer().getPluginManager();
pm.registerEvents(new dev.loki.loparkour.listener.PlayerConnectionListener(), this);
pm.registerEvents(new dev.loki.loparkour.listener.ParkourRestrictionListener(), this);
pm.registerEvents(new dev.loki.loparkour.listener.SchematicWandListener(), this);
LoParkourCommand cmd = new LoParkourCommand();
getCommand("LoParkour").setExecutor(cmd);
getCommand("LoParkour").setTabCompleter(cmd);
}
private void setupMetrics() {
Metrics metrics = new Metrics(this, 29754);
metrics.addCustomChart(new SimplePie("using_sql", () -> Boolean.toString(Option.SQL)));
metrics.addCustomChart(new SimplePie("using_rewards", () -> Boolean.toString(Rewards.REWARDS_ENABLED)));
metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.getLocaleCount())));
metrics.addCustomChart(new SingleLineChart("player_joins", () -> {
int joins = UserRegistry.getJoinCount();
return joins;
}));
instance = this;
PluginBootstrap.enable(this);
}
@Override
public void disable() {
PluginBootstrap.disable(this);
}
/** Called from {@link PluginBootstrap} on shutdown. */
public void runShutdownSequence() {
try {
// Clear all sessions BEFORE leaving players to prevent memory leaks
new ArrayList<>(Divider.sections.keySet()).forEach(session -> {
try {
session.onAllPlayersLeft();
@ -194,23 +96,19 @@ public final class LoParkour extends LoPlugin {
getLogger().warning("Error cleaning up session: " + e.getMessage());
}
});
// Remove all sessions from Divider
new ArrayList<>(Divider.sections.keySet()).forEach(Divider::remove);
// Now leave all players
for (ParkourUser user : ParkourUser.getUsers()) {
ParkourUser.leave(user);
}
// write all LoParkour gamemodes (may be null if enable failed)
if (Modes.DEFAULT != null && Modes.DEFAULT.getLeaderboard() != null) {
Modes.DEFAULT.getLeaderboard().write(false);
}
// Clear rewards to prevent memory leaks
dev.loki.loparkour.reward.Rewards.clear();
Rewards.clear();
dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.shutdown();
Storage.close();
World.delete();
} catch (Throwable t) {

View file

@ -0,0 +1,172 @@
package dev.loki.loparkour.adaptive.bootstrap;
import dev.loki.loparkour.adaptive.core.AdaptiveDifficulty;
import dev.loki.loparkour.adaptive.core.MetricsCollector;
import dev.loki.loparkour.adaptive.model.AdaptiveConfig;
import dev.loki.loparkour.adaptive.model.SkillRating;
import dev.loki.loparkour.adaptive.storage.FileStatsStorage;
import dev.loki.loparkour.adaptive.storage.SQLStatsStorage;
import dev.loki.loparkour.adaptive.storage.StatsRepository;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.core.model.Profile;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.storage.sql.StorageSQL;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* Bootstrap for the adaptive difficulty subsystem.
*/
public final class AdaptiveServices {
@Nullable
private static Plugin plugin;
@Nullable
private static StatsRepository repository;
@Nullable
private static MetricsCollector metricsCollector;
private AdaptiveServices() {
}
public static void init(@NotNull Plugin pluginInstance) {
plugin = pluginInstance;
AdaptiveConfig.init();
if (!AdaptiveConfig.isEnabled()) {
return;
}
Runnable setup = () -> startCollector(createRepository());
if (Option.SQL) {
Storage.runWhenReady(setup);
} else {
setup.run();
}
}
public static void reload() {
shutdownCollector();
AdaptiveConfig.reload();
if (plugin != null && AdaptiveConfig.isEnabled()) {
Runnable setup = () -> startCollector(createRepository());
if (Option.SQL) {
Storage.runWhenReady(setup);
} else {
setup.run();
}
}
}
public static void attachToGenerator(@NotNull ParkourGenerator generator) {
if (metricsCollector != null) {
generator.registerEventListener(metricsCollector);
}
}
/**
* Applies adaptive difficulty weights to a profile being built for a generator.
*/
public static void applyAdaptiveProfile(@NotNull ParkourGenerator generator, @NotNull Profile profile) {
if (!AdaptiveConfig.isEnabled() || metricsCollector == null) {
return;
}
double target = AdaptiveDifficulty.resolveTargetDifficulty(generator, AdaptiveServices::resolveSkillRating);
AdaptiveDifficulty.applyToProfile(profile, target);
}
/**
* Updates stored skill rating from current session metrics after a fall.
*/
public static void persistRatingAfterFall(@NotNull ParkourPlayer player) {
if (!AdaptiveConfig.isEnabled() || repository == null || metricsCollector == null) {
return;
}
UUID uuid = player.getUUID();
SkillRating session = AdaptiveDifficulty.analyzeSession(player, metricsCollector);
SkillRating stored = repository.loadSkillRating(uuid);
if (stored != null) {
stored.updateRating(session.getRating(), AdaptiveConfig.getRatingWeight());
repository.saveSkillRating(stored);
} else {
repository.saveSkillRating(session);
}
}
/**
* Rebuilds generator profile/chances from latest ratings (call once per fall event).
*/
public static void refreshGeneratorDifficulty(@NotNull ParkourGenerator generator) {
if (!AdaptiveConfig.isEnabled()) {
return;
}
generator.profileManager.recalculateProfile();
}
public static void unloadPlayer(@NotNull UUID playerUuid) {
if (metricsCollector != null) {
metricsCollector.unloadPlayer(playerUuid);
}
}
public static void shutdown() {
shutdownCollector();
repository = null;
}
@NotNull
private static SkillRating resolveSkillRating(@NotNull ParkourPlayer player) {
UUID uuid = player.getUUID();
SkillRating stored = repository != null ? repository.loadSkillRating(uuid) : null;
if (metricsCollector == null) {
return stored != null ? stored : new SkillRating(uuid);
}
var metrics = metricsCollector.getMetrics(uuid);
if (metrics.getTotalJumps() < AdaptiveConfig.getMinSessions()) {
if (stored != null && stored.isReliable()) {
return stored;
}
return stored != null ? stored : new SkillRating(uuid);
}
SkillRating session = AdaptiveDifficulty.analyzeSession(player, metricsCollector);
if (stored == null) {
return session;
}
return AdaptiveDifficulty.mergeSessionRating(stored, session);
}
@NotNull
private static StatsRepository createRepository() {
if (Option.SQL && StorageSQL.isConnected()) {
return new SQLStatsStorage(StorageSQL.getConnectionManager());
}
return new FileStatsStorage();
}
private static void startCollector(@NotNull StatsRepository repo) {
if (plugin == null) {
return;
}
repository = repo;
metricsCollector = new MetricsCollector(plugin, repo);
metricsCollector.startAutoSave();
}
private static void shutdownCollector() {
if (metricsCollector != null) {
metricsCollector.stopAutoSave();
metricsCollector.saveAllMetrics();
metricsCollector = null;
}
}
}

View file

@ -0,0 +1,90 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.adaptive.model.AdaptiveConfig;
import dev.loki.loparkour.adaptive.model.SkillRating;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.core.model.Profile;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import java.util.Set;
/**
* Applies adaptive difficulty to a generator {@link Profile}.
*/
public final class AdaptiveDifficulty {
private static final DifficultyCalculator CALCULATOR = new DifficultyCalculator();
private static final DifficultyAdjuster ADJUSTER = new DifficultyAdjuster();
private static final SkillAnalyzer ANALYZER = new SkillAnalyzer();
private static final Set<String> DIFFICULTY_PREFIXES = Set.of(
"distance.", "height.", "type."
);
private AdaptiveDifficulty() {
}
/**
* Merges difficulty-related keys from {@code adjusted} into {@code target}.
*/
public static void mergeDifficultySettings(@NotNull Profile adjusted, @NotNull Profile target) {
for (String key : adjusted.settings.keySet()) {
if (isDifficultyKey(key)) {
target.set(key, adjusted.get(key).value());
}
}
}
public static double resolveTargetDifficulty(
@NotNull ParkourGenerator generator,
@NotNull SkillRatingResolver resolver) {
var players = generator.getPlayers();
if (players.isEmpty()) {
return 0.5;
}
double weightedSum = 0.0;
double weightTotal = 0.0;
for (ParkourPlayer player : players) {
SkillRating rating = resolver.resolve(player);
double target = CALCULATOR.calculateTargetDifficulty(rating);
double weight = Math.max(0.1, rating.getConfidence());
weightedSum += target * weight;
weightTotal += weight;
}
return weightTotal > 0 ? weightedSum / weightTotal : 0.5;
}
@NotNull
public static SkillRating mergeSessionRating(@NotNull SkillRating stored, @NotNull SkillRating sessionSample) {
double weight = AdaptiveConfig.getRatingWeight();
double merged = (stored.getRating() * (1.0 - weight)) + (sessionSample.getRating() * weight);
double confidence = Math.max(stored.getConfidence(), sessionSample.getConfidence());
return new SkillRating(stored.getPlayerUuid(), merged, confidence, stored.getSessionsCount() + 1);
}
@NotNull
public static SkillRating analyzeSession(@NotNull ParkourPlayer player, @NotNull MetricsCollector collector) {
return ANALYZER.analyzeSkill(collector.getMetrics(player.getUUID()));
}
public static void applyToProfile(
@NotNull Profile baseProfile,
double targetDifficulty) {
Profile adjusted = ADJUSTER.adjustProfile(baseProfile, targetDifficulty);
mergeDifficultySettings(adjusted, baseProfile);
}
private static boolean isDifficultyKey(@NotNull String key) {
return DIFFICULTY_PREFIXES.stream().anyMatch(key::startsWith);
}
@FunctionalInterface
public interface SkillRatingResolver {
@NotNull
SkillRating resolve(@NotNull ParkourPlayer player);
}
}

View file

@ -0,0 +1,182 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.generator.core.model.Profile;
import org.jetbrains.annotations.NotNull;
/**
* Adjusts difficulty profile parameters based on target difficulty.
* Modifies distance, height, and special block chances to match player skill.
*
* Difficulty scale (0.0-1.0):
* - 0.0-0.3: Easy (short jumps, minimal height variation)
* - 0.3-0.6: Medium (balanced jumps, moderate height)
* - 0.6-0.8: Hard (longer jumps, more height variation)
* - 0.8-1.0: Expert (maximum distance, high variation)
*
* Thread-safe: Stateless, all methods are pure functions.
*/
public class DifficultyAdjuster {
// Distance bounds (blocks)
private static final int MIN_DISTANCE = 1;
private static final int MAX_DISTANCE = 4;
// Height bounds (blocks)
private static final int MIN_HEIGHT = -1;
private static final int MAX_HEIGHT = 2;
/**
* Adjusts a profile to match the target difficulty.
* Creates a new profile with modified parameters.
*
* @param baseProfile The base profile to adjust
* @param targetDifficulty Target difficulty (0.0-1.0)
* @return New profile with adjusted parameters
*/
@NotNull
public Profile adjustProfile(@NotNull Profile baseProfile, double targetDifficulty) {
Profile adjusted = new Profile();
// Copy base settings
copyBaseSettings(baseProfile, adjusted);
// Adjust difficulty parameters
adjustDistanceChances(adjusted, targetDifficulty);
adjustHeightChances(adjusted, targetDifficulty);
adjustSpecialFrequency(adjusted, targetDifficulty);
return adjusted;
}
/**
* Copies non-difficulty settings from base profile.
*/
private void copyBaseSettings(@NotNull Profile source, @NotNull Profile target) {
String[] settingsKeys = {"min-y", "max-y", "heading", "first-block-material"};
for (String key : settingsKeys) {
if (source.settings.containsKey(key)) {
target.set(key, source.get(key).value());
}
}
}
/**
* Adjusts distance chances based on difficulty.
*/
private void adjustDistanceChances(@NotNull Profile profile, double difficulty) {
double[] weights = DifficultyWeights.calculateDistanceWeights(difficulty);
profile.set("distance.min", String.valueOf(MIN_DISTANCE));
profile.set("distance.max", String.valueOf(MAX_DISTANCE));
for (int distance = MIN_DISTANCE; distance <= MAX_DISTANCE; distance++) {
int index = distance - MIN_DISTANCE;
int chance = (int) Math.round(weights[index] * 100);
profile.set("distance.chances." + distance, String.valueOf(chance));
}
}
/**
* Adjusts height variation based on difficulty.
*/
private void adjustHeightChances(@NotNull Profile profile, double difficulty) {
double[] weights = DifficultyWeights.calculateHeightWeights(difficulty);
profile.set("height.min", String.valueOf(MIN_HEIGHT));
profile.set("height.max", String.valueOf(MAX_HEIGHT));
int index = 0;
for (int height = MIN_HEIGHT; height <= MAX_HEIGHT; height++) {
int chance = (int) Math.round(weights[index] * 100);
profile.set("height.chances." + height, String.valueOf(chance));
index++;
}
}
/**
* Adjusts special block frequency based on difficulty.
*/
private void adjustSpecialFrequency(@NotNull Profile profile, double difficulty) {
double specialPercent = DifficultyWeights.calculateSpecialFrequency(difficulty);
int defaultPercent = (int) Math.round(100.0 - specialPercent);
int specialChance = (int) Math.round(specialPercent * 0.75);
int schematicChance = (int) Math.round(specialPercent * 0.25);
profile.set("type.default", String.valueOf(defaultPercent));
profile.set("type.special", String.valueOf(specialChance));
profile.set("type.schematic", String.valueOf(schematicChance));
}
/**
* Calculates the effective difficulty of a profile.
* Inverse operation of adjustProfile.
*
* @param profile The profile to analyze
* @return Estimated difficulty (0.0-1.0)
*/
public double estimateDifficulty(@NotNull Profile profile) {
double distanceDifficulty = estimateDistanceDifficulty(profile);
double heightDifficulty = estimateHeightDifficulty(profile);
double specialDifficulty = estimateSpecialDifficulty(profile);
// Weighted average
return (distanceDifficulty * 0.5) + (heightDifficulty * 0.3) + (specialDifficulty * 0.2);
}
private double estimateDistanceDifficulty(@NotNull Profile profile) {
double avgDistance = 0.0;
double totalWeight = 0.0;
for (int distance = MIN_DISTANCE; distance <= MAX_DISTANCE; distance++) {
String key = "distance.chances." + distance;
if (profile.settings.containsKey(key)) {
double weight = profile.get(key).asDouble();
avgDistance += distance * weight;
totalWeight += weight;
}
}
if (totalWeight > 0) {
avgDistance /= totalWeight;
}
// Map average distance to difficulty (1.0 = easy, 4.0 = hard)
return (avgDistance - 1.0) / 3.0;
}
private double estimateHeightDifficulty(@NotNull Profile profile) {
double variation = 0.0;
double totalWeight = 0.0;
for (int height = MIN_HEIGHT; height <= MAX_HEIGHT; height++) {
String key = "height.chances." + height;
if (profile.settings.containsKey(key)) {
double weight = profile.get(key).asDouble();
variation += Math.abs(height) * weight;
totalWeight += weight;
}
}
if (totalWeight > 0) {
variation /= totalWeight;
}
// Map variation to difficulty (0.0 = flat, 1.5 = varied)
return Math.min(1.0, variation / 1.5);
}
private double estimateSpecialDifficulty(@NotNull Profile profile) {
if (!profile.settings.containsKey("type.special")) {
return 0.0;
}
double specialPercent = profile.get("type.special").asDouble();
double minFrequency = 5.0;
double maxFrequency = 30.0;
// Map special percentage to difficulty
return (specialPercent - minFrequency) / (maxFrequency - minFrequency);
}
}

View file

@ -0,0 +1,141 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.adaptive.model.SkillRating;
import org.jetbrains.annotations.NotNull;
/**
* Calculates target difficulty based on player skill rating.
* Converts skill ratings into normalized difficulty values (0.0-1.0).
*
* Formula: difficulty = min(1.0, rating / 2.0)
* - Rating 0.0 = 0.0 difficulty (easiest)
* - Rating 1.0 = 0.5 difficulty (medium)
* - Rating 2.0+ = 1.0 difficulty (hardest)
*
* Thread-safe: Stateless, all methods are pure functions.
*/
public class DifficultyCalculator {
private static final double MAX_RATING_FOR_FULL_DIFFICULTY = 2.0;
private static final double MIN_DIFFICULTY = 0.0;
private static final double MAX_DIFFICULTY = 1.0;
/**
* Calculates the target difficulty for a player based on their skill rating.
*
* @param skillRating The player's skill rating
* @return Target difficulty (0.0-1.0)
*/
public double calculateTargetDifficulty(@NotNull SkillRating skillRating) {
double rating = skillRating.getRating();
double confidence = skillRating.getConfidence();
// Calculate base difficulty from rating
double baseDifficulty = calculateBaseDifficulty(rating);
// Apply confidence adjustment (lower confidence = more conservative difficulty)
double adjustedDifficulty = applyConfidenceAdjustment(baseDifficulty, confidence);
return clampDifficulty(adjustedDifficulty);
}
/**
* Calculates base difficulty from skill rating.
* Linear mapping: rating 2.0 = difficulty 1.0
*
* @param rating The skill rating
* @return Base difficulty (0.0-1.0+)
*/
private double calculateBaseDifficulty(double rating) {
return rating / MAX_RATING_FOR_FULL_DIFFICULTY;
}
/**
* Applies confidence adjustment to difficulty.
* Low confidence ratings result in more conservative difficulty.
*
* @param baseDifficulty The base difficulty
* @param confidence The confidence level (0.0-1.0)
* @return Adjusted difficulty
*/
private double applyConfidenceAdjustment(double baseDifficulty, double confidence) {
// If confidence is low, reduce difficulty to be safe
// confidence 0.0 = 50% of base difficulty
// confidence 1.0 = 100% of base difficulty
double confidenceMultiplier = 0.5 + (confidence * 0.5);
return baseDifficulty * confidenceMultiplier;
}
/**
* Clamps difficulty to valid range [0.0, 1.0].
*
* @param difficulty The difficulty value
* @return Clamped difficulty
*/
private double clampDifficulty(double difficulty) {
return Math.max(MIN_DIFFICULTY, Math.min(MAX_DIFFICULTY, difficulty));
}
/**
* Calculates the recommended rating for a target difficulty.
* Inverse of calculateTargetDifficulty (without confidence adjustment).
*
* @param targetDifficulty The desired difficulty (0.0-1.0)
* @return Recommended skill rating
*/
public double calculateRecommendedRating(double targetDifficulty) {
double clampedDifficulty = clampDifficulty(targetDifficulty);
return clampedDifficulty * MAX_RATING_FOR_FULL_DIFFICULTY;
}
/**
* Determines if a player is ready for increased difficulty.
* Checks if rating is significantly above current difficulty level.
*
* @param skillRating The player's skill rating
* @param currentDifficulty The current difficulty (0.0-1.0)
* @return true if player is ready for harder content
*/
public boolean isReadyForIncrease(@NotNull SkillRating skillRating, double currentDifficulty) {
double targetDifficulty = calculateTargetDifficulty(skillRating);
double threshold = 0.1; // 10% increase threshold
return targetDifficulty > currentDifficulty + threshold
&& skillRating.getConfidence() >= 0.5; // Require reasonable confidence
}
/**
* Determines if difficulty should be decreased for a player.
* Checks if rating is significantly below current difficulty level.
*
* @param skillRating The player's skill rating
* @param currentDifficulty The current difficulty (0.0-1.0)
* @return true if difficulty should be reduced
*/
public boolean shouldDecrease(@NotNull SkillRating skillRating, double currentDifficulty) {
double targetDifficulty = calculateTargetDifficulty(skillRating);
double threshold = 0.15; // 15% decrease threshold (more aggressive than increase)
return targetDifficulty < currentDifficulty - threshold;
}
/**
* Calculates a smooth difficulty transition.
* Prevents sudden difficulty spikes by limiting change rate.
*
* @param currentDifficulty The current difficulty
* @param targetDifficulty The target difficulty
* @param maxChange Maximum allowed change per adjustment
* @return Smoothed difficulty
*/
public double smoothTransition(double currentDifficulty, double targetDifficulty, double maxChange) {
double difference = targetDifficulty - currentDifficulty;
if (Math.abs(difference) <= maxChange) {
return targetDifficulty;
}
// Move towards target by maxChange
return currentDifficulty + Math.signum(difference) * maxChange;
}
}

View file

@ -0,0 +1,98 @@
package dev.loki.loparkour.adaptive.core;
/**
* Calculates weight distributions for different difficulty levels.
* Contains formulas for distance, height, and special block distributions.
*
* Thread-safe: Stateless, all methods are pure functions.
*/
class DifficultyWeights {
/**
* Calculates distance weight distribution for given difficulty.
*
* @param difficulty Target difficulty (0.0-1.0)
* @return Array of weights [dist1, dist2, dist3, dist4]
*/
static double[] calculateDistanceWeights(double difficulty) {
double[] weights = new double[4];
if (difficulty < 0.3) {
// Easy: favor short jumps (1-2 blocks)
weights[0] = 0.40; // 1 block
weights[1] = 0.40; // 2 blocks
weights[2] = 0.15; // 3 blocks
weights[3] = 0.05; // 4 blocks
} else if (difficulty < 0.6) {
// Medium: balanced distribution
weights[0] = 0.20; // 1 block
weights[1] = 0.35; // 2 blocks
weights[2] = 0.30; // 3 blocks
weights[3] = 0.15; // 4 blocks
} else if (difficulty < 0.8) {
// Hard: favor longer jumps
weights[0] = 0.10; // 1 block
weights[1] = 0.25; // 2 blocks
weights[2] = 0.40; // 3 blocks
weights[3] = 0.25; // 4 blocks
} else {
// Expert: maximum challenge
weights[0] = 0.05; // 1 block
weights[1] = 0.15; // 2 blocks
weights[2] = 0.35; // 3 blocks
weights[3] = 0.45; // 4 blocks
}
return weights;
}
/**
* Calculates height weight distribution for given difficulty.
*
* @param difficulty Target difficulty (0.0-1.0)
* @return Array of weights [-1, 0, 1, 2]
*/
static double[] calculateHeightWeights(double difficulty) {
double[] weights = new double[4];
if (difficulty < 0.3) {
// Easy: mostly flat, minimal drops
weights[0] = 0.10; // -1 (down)
weights[1] = 0.60; // 0 (flat)
weights[2] = 0.25; // +1 (up)
weights[3] = 0.05; // +2 (high up)
} else if (difficulty < 0.6) {
// Medium: balanced variation
weights[0] = 0.20; // -1
weights[1] = 0.40; // 0
weights[2] = 0.30; // +1
weights[3] = 0.10; // +2
} else if (difficulty < 0.8) {
// Hard: more variation
weights[0] = 0.25; // -1
weights[1] = 0.30; // 0
weights[2] = 0.30; // +1
weights[3] = 0.15; // +2
} else {
// Expert: maximum variation
weights[0] = 0.30; // -1
weights[1] = 0.20; // 0
weights[2] = 0.30; // +1
weights[3] = 0.20; // +2
}
return weights;
}
/**
* Calculates special block frequency for given difficulty.
*
* @param difficulty Target difficulty (0.0-1.0)
* @return Special block percentage (5.0-30.0)
*/
static double calculateSpecialFrequency(double difficulty) {
double minFrequency = 5.0;
double maxFrequency = 30.0;
return minFrequency + (maxFrequency - minFrequency) * difficulty;
}
}

View file

@ -0,0 +1,214 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.adaptive.storage.StatsRepository;
import dev.loki.loparkour.generator.core.coordinator.GeneratorEventListener;
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.
*
* Responsibilities:
* - Track player jumps, falls, and scoring events
* - Detect near-miss jumps (distance > 3.5 blocks)
* - Calculate average time per block
* - Maintain jump type statistics
* - Auto-save metrics every 5 minutes
*
* Thread-safe: Uses ConcurrentHashMap for player metrics cache.
*/
public class MetricsCollector implements GeneratorEventListener {
private static final double NEAR_MISS_THRESHOLD = 3.5;
private static final long AUTO_SAVE_INTERVAL_TICKS = 20 * 60 * 5; // 5 minutes
private final Plugin plugin;
private final StatsRepository repository;
private final Map<UUID, PlayerMetrics> metricsCache;
private final Map<UUID, Block> lastBlockCache;
private final Map<UUID, Long> lastBlockTimeCache;
private BukkitRunnable autoSaveTask;
/**
* Create a new MetricsCollector.
*
* @param plugin The plugin instance for scheduling tasks
* @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<>();
}
/**
* Start the auto-save task.
* 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);
}
/**
* Stop the auto-save task.
* Should be called when the plugin is disabled.
*/
public void stopAutoSave() {
if (autoSaveTask != null) {
autoSaveTask.cancel();
autoSaveTask = null;
}
}
/**
* Get or create metrics for a player.
*
* @param playerUuid The player's UUID
* @return The player's metrics
*/
@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);
});
}
/**
* Called when a player lands on a block.
* Detects near-miss jumps and updates timing metrics.
*
* @param player The player who landed
* @param block The block the player landed on
* @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);
}
@Override
public void onBlockGenerated(@NotNull Player player, @NotNull Block block, @NotNull JumpType jumpType, double distance) {
// Not used for metrics collection, but required by interface
}
@Override
public void onPlayerScore(@NotNull Player player, @NotNull Block block, double distance) {
onBlockLand(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);
}
@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);
}
/**
* Save metrics for a specific player.
*
* @param playerUuid The player's UUID
*/
public void saveMetrics(@NotNull UUID playerUuid) {
PlayerMetrics metrics = metricsCache.get(playerUuid);
if (metrics != null) {
repository.saveMetrics(metrics);
}
}
/**
* Save all cached metrics.
* Called by auto-save task and on plugin disable.
*/
public void saveAllMetrics() {
for (PlayerMetrics metrics : metricsCache.values()) {
repository.saveMetrics(metrics);
}
}
/**
* Remove a player from the cache.
* Should be called when a player leaves the server.
*
* @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);
}
/**
* Get the number of cached players.
*
* @return The cache size
*/
public int getCacheSize() {
return metricsCache.size();
}
}

View file

@ -0,0 +1,137 @@
package dev.loki.loparkour.adaptive.core;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.adaptive.model.SkillRating;
import org.jetbrains.annotations.NotNull;
/**
* Analyzes player metrics and calculates skill ratings.
* Uses a formula that considers success rate, near misses, and consistency.
*
* Formula: rating = baseRating * (successRate^2) * (1 + nearMissBonus) * streakMultiplier
* - baseRating: 1.0 (baseline skill level)
* - successRate: percentage of successful jumps (0.0-1.0)
* - nearMissBonus: 0.1 per near miss (rewards precision)
* - streakMultiplier: 1 + (successfulJumps / 100) (rewards consistency)
*
* Thread-safe: Stateless, all methods are pure functions.
*/
public class SkillAnalyzer {
private static final double BASE_RATING = 1.0;
private static final double NEAR_MISS_BONUS = 0.1;
private static final double STREAK_DIVISOR = 100.0;
private static final int MIN_JUMPS_FOR_CONFIDENCE = 10;
private static final int MAX_JUMPS_FOR_CONFIDENCE = 100;
/**
* Analyzes player metrics and calculates a skill rating.
*
* @param metrics The player's performance metrics
* @return A skill rating with confidence level
*/
@NotNull
public SkillRating analyzeSkill(@NotNull PlayerMetrics metrics) {
double successRate = metrics.getSuccessRate();
int nearMisses = metrics.getNearMissCount();
int successfulJumps = metrics.getSuccessfulJumps();
int totalJumps = metrics.getTotalJumps();
// Calculate rating components
double successComponent = calculateSuccessComponent(successRate);
double nearMissBonus = calculateNearMissBonus(nearMisses);
double streakMultiplier = calculateStreakMultiplier(successfulJumps);
// Combine components
double rating = BASE_RATING * successComponent * nearMissBonus * streakMultiplier;
// Calculate confidence based on sample size
double confidence = calculateConfidence(totalJumps);
// Sessions count is 1 for current session analysis
int sessionsCount = 1;
return new SkillRating(metrics.getPlayerUuid(), rating, confidence, sessionsCount);
}
/**
* Calculates the success rate component of the rating.
* Uses squared success rate to emphasize consistency.
*
* @param successRate The player's success rate (0.0-1.0)
* @return Success component (0.0-1.0)
*/
private double calculateSuccessComponent(double successRate) {
return Math.pow(successRate, 2);
}
/**
* Calculates the near miss bonus multiplier.
* Rewards players who land close to block edges (precision).
*
* @param nearMisses Number of near miss landings
* @return Near miss multiplier (1.0+)
*/
private double calculateNearMissBonus(int nearMisses) {
return 1.0 + (nearMisses * NEAR_MISS_BONUS);
}
/**
* Calculates the streak multiplier based on successful jumps.
* Rewards consistent performance over time.
*
* @param successfulJumps Number of successful jumps
* @return Streak multiplier (1.0+)
*/
private double calculateStreakMultiplier(int successfulJumps) {
return 1.0 + (successfulJumps / STREAK_DIVISOR);
}
/**
* Calculates confidence level based on sample size.
* More jumps = higher confidence in the rating.
*
* @param totalJumps Total number of jumps attempted
* @return Confidence level (0.0-1.0)
*/
private double calculateConfidence(int totalJumps) {
if (totalJumps < MIN_JUMPS_FOR_CONFIDENCE) {
// Low confidence for small samples
return (double) totalJumps / MIN_JUMPS_FOR_CONFIDENCE;
} else if (totalJumps >= MAX_JUMPS_FOR_CONFIDENCE) {
// Maximum confidence reached
return 1.0;
} else {
// Linear interpolation between min and max
double range = MAX_JUMPS_FOR_CONFIDENCE - MIN_JUMPS_FOR_CONFIDENCE;
double progress = totalJumps - MIN_JUMPS_FOR_CONFIDENCE;
return progress / range;
}
}
/**
* Estimates the minimum rating for a given success rate.
* Useful for UI display and difficulty selection.
*
* @param successRate The success rate (0.0-1.0)
* @return Estimated minimum rating
*/
public double estimateMinRating(double successRate) {
return BASE_RATING * calculateSuccessComponent(successRate);
}
/**
* Estimates the maximum rating for a given success rate.
* Assumes high near miss count and long streak.
*
* @param successRate The success rate (0.0-1.0)
* @return Estimated maximum rating
*/
public double estimateMaxRating(double successRate) {
double successComponent = calculateSuccessComponent(successRate);
double maxNearMissBonus = calculateNearMissBonus(50); // Assume 50 near misses
double maxStreakMultiplier = calculateStreakMultiplier(MAX_JUMPS_FOR_CONFIDENCE);
return BASE_RATING * successComponent * maxNearMissBonus * maxStreakMultiplier;
}
}

View file

@ -0,0 +1,158 @@
package dev.loki.loparkour.adaptive.model;
import dev.loki.loparkour.config.core.Config;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.ConcurrentHashMap;
/**
* Cached configuration for the adaptive difficulty system.
* Loads settings from config.yml and caches them for performance.
*/
public class AdaptiveConfig {
private static final ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
private static volatile boolean initialized = false;
// Configuration keys
private static final String KEY_ENABLED = "adaptive.enabled";
private static final String KEY_MIN_SESSIONS = "adaptive.min-sessions";
private static final String KEY_UPDATE_INTERVAL = "adaptive.update-interval";
private static final String KEY_RATING_WEIGHT = "adaptive.rating-weight";
private static final String KEY_CONFIDENCE_THRESHOLD = "adaptive.confidence-threshold";
private static final String KEY_NEAR_MISS_DISTANCE = "adaptive.near-miss-distance";
// Default values
private static final boolean DEFAULT_ENABLED = true;
private static final int DEFAULT_MIN_SESSIONS = 3;
private static final int DEFAULT_UPDATE_INTERVAL = 300; // 5 minutes in seconds
private static final double DEFAULT_RATING_WEIGHT = 0.15;
private static final double DEFAULT_CONFIDENCE_THRESHOLD = 0.3;
private static final double DEFAULT_NEAR_MISS_DISTANCE = 0.3;
/**
* Initializes the configuration cache from config.yml.
* Should be called during plugin startup.
*/
public static void init() {
if (initialized) {
return;
}
cache.put(KEY_ENABLED, Config.CONFIG.getBoolean(KEY_ENABLED, DEFAULT_ENABLED));
cache.put(KEY_MIN_SESSIONS, Config.CONFIG.getInt(KEY_MIN_SESSIONS, DEFAULT_MIN_SESSIONS));
cache.put(KEY_UPDATE_INTERVAL, Config.CONFIG.getInt(KEY_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL));
cache.put(KEY_RATING_WEIGHT, configDouble(KEY_RATING_WEIGHT, DEFAULT_RATING_WEIGHT));
cache.put(KEY_CONFIDENCE_THRESHOLD, configDouble(KEY_CONFIDENCE_THRESHOLD, DEFAULT_CONFIDENCE_THRESHOLD));
cache.put(KEY_NEAR_MISS_DISTANCE, configDouble(KEY_NEAR_MISS_DISTANCE, DEFAULT_NEAR_MISS_DISTANCE));
initialized = true;
}
/**
* Reloads configuration from config.yml.
* Call this after config reload.
*/
public static void reload() {
initialized = false;
cache.clear();
init();
}
/**
* Checks if adaptive difficulty system is enabled.
*
* @return True if enabled
*/
public static boolean isEnabled() {
ensureInitialized();
return (boolean) cache.getOrDefault(KEY_ENABLED, DEFAULT_ENABLED);
}
/**
* Gets minimum number of sessions before adaptive difficulty activates.
*
* @return Minimum sessions count
*/
public static int getMinSessions() {
ensureInitialized();
return (int) cache.getOrDefault(KEY_MIN_SESSIONS, DEFAULT_MIN_SESSIONS);
}
/**
* Gets the interval (in seconds) between automatic stats updates.
*
* @return Update interval in seconds
*/
public static int getUpdateInterval() {
ensureInitialized();
return (int) cache.getOrDefault(KEY_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL);
}
/**
* Gets the weight for rating updates (how much new performance affects rating).
*
* @return Rating weight (0.0 to 1.0)
*/
public static double getRatingWeight() {
ensureInitialized();
Object value = cache.get(KEY_RATING_WEIGHT);
if (value instanceof Double) {
return (double) value;
}
return DEFAULT_RATING_WEIGHT;
}
/**
* Gets the confidence threshold for reliable ratings.
*
* @return Confidence threshold (0.0 to 1.0)
*/
public static double getConfidenceThreshold() {
ensureInitialized();
Object value = cache.get(KEY_CONFIDENCE_THRESHOLD);
if (value instanceof Double) {
return (double) value;
}
return DEFAULT_CONFIDENCE_THRESHOLD;
}
/**
* Gets the distance threshold for near-miss detection.
*
* @return Near-miss distance in blocks
*/
public static double getNearMissDistance() {
ensureInitialized();
Object value = cache.get(KEY_NEAR_MISS_DISTANCE);
if (value instanceof Double) {
return (double) value;
}
return DEFAULT_NEAR_MISS_DISTANCE;
}
private static void ensureInitialized() {
if (!initialized) {
init();
}
}
private static double configDouble(@NotNull String key, double defaultValue) {
return Config.CONFIG.isPath(key) ? Config.CONFIG.getDouble(key) : defaultValue;
}
/**
* Gets a custom config value with caching.
*
* @param key The config key
* @param defaultValue Default value if key not found
* @return The config value
*/
@NotNull
public static <T> T get(@NotNull String key, @NotNull T defaultValue) {
ensureInitialized();
@SuppressWarnings("unchecked")
T value = (T) cache.get(key);
return value != null ? value : defaultValue;
}
}

View file

@ -0,0 +1,159 @@
package dev.loki.loparkour.adaptive.model;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Stores detailed player performance metrics for adaptive difficulty calculation.
* Tracks timing, jump patterns, and near-miss statistics.
*/
public class PlayerMetrics {
private final UUID playerUuid;
private double avgTimePerBlock;
private final Map<String, Integer> jumpTypeStats;
private int nearMissCount;
private int totalJumps;
private long lastUpdated;
public PlayerMetrics(@NotNull UUID playerUuid) {
this.playerUuid = playerUuid;
this.avgTimePerBlock = 0.0;
this.jumpTypeStats = new HashMap<>();
this.nearMissCount = 0;
this.totalJumps = 0;
this.lastUpdated = System.currentTimeMillis();
}
/**
* Records a successful jump with timing data.
*
* @param jumpType The type of jump (e.g., "normal", "diagonal", "momentum")
* @param timeMs Time taken for this jump in milliseconds
*/
public void recordJump(@NotNull String jumpType, long timeMs) {
jumpTypeStats.merge(jumpType, 1, Integer::sum);
totalJumps++;
// Update rolling average
if (avgTimePerBlock == 0.0) {
avgTimePerBlock = timeMs;
} else {
avgTimePerBlock = (avgTimePerBlock * 0.9) + (timeMs * 0.1);
}
lastUpdated = System.currentTimeMillis();
}
/**
* Records a near-miss event (landed close to block edge).
*/
public void recordNearMiss() {
nearMissCount++;
lastUpdated = System.currentTimeMillis();
}
/**
* Gets the frequency of a specific jump type.
*
* @param jumpType The jump type to query
* @return Percentage (0.0 to 1.0) of total jumps
*/
public double getJumpTypeFrequency(@NotNull String jumpType) {
if (totalJumps == 0) {
return 0.0;
}
return jumpTypeStats.getOrDefault(jumpType, 0) / (double) totalJumps;
}
/**
* Gets near-miss rate as percentage of total jumps.
*
* @return Near-miss rate (0.0 to 1.0)
*/
public double getNearMissRate() {
if (totalJumps == 0) {
return 0.0;
}
return nearMissCount / (double) totalJumps;
}
/**
* Calculates success rate based on total jumps.
* Assumes all recorded jumps are successful (falls are tracked separately).
*
* @return Success rate (0.0 to 1.0)
*/
public double getSuccessRate() {
// Since we only record successful jumps, success rate is based on
// the assumption that player has some failures not tracked here
// For now, return 1.0 if we have jumps, or use near-miss rate as proxy
if (totalJumps == 0) {
return 0.0;
}
// High near-miss rate indicates good performance
return Math.min(1.0, 0.7 + (getNearMissRate() * 0.3));
}
/**
* Gets the number of successful jumps (same as total jumps for now).
*
* @return Number of successful jumps
*/
public int getSuccessfulJumps() {
return totalJumps;
}
// Getters
@NotNull
public UUID getPlayerUuid() {
return playerUuid;
}
public double getAvgTimePerBlock() {
return avgTimePerBlock;
}
@NotNull
public Map<String, Integer> getJumpTypeStats() {
return new HashMap<>(jumpTypeStats);
}
public int getNearMissCount() {
return nearMissCount;
}
public int getTotalJumps() {
return totalJumps;
}
public long getLastUpdated() {
return lastUpdated;
}
// Setters for deserialization
public void setAvgTimePerBlock(double avgTimePerBlock) {
this.avgTimePerBlock = avgTimePerBlock;
}
public void setNearMissCount(int nearMissCount) {
this.nearMissCount = nearMissCount;
}
public void setTotalJumps(int totalJumps) {
this.totalJumps = totalJumps;
}
public void setLastUpdated(long lastUpdated) {
this.lastUpdated = lastUpdated;
}
public void putJumpTypeStat(@NotNull String jumpType, int count) {
jumpTypeStats.put(jumpType, count);
}
}

View file

@ -0,0 +1,133 @@
package dev.loki.loparkour.adaptive.model;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Represents a player's skill rating with confidence level.
* Used for adaptive difficulty calculation.
*/
public class SkillRating {
private final UUID playerUuid;
private double rating;
private double confidence;
private int sessionsCount;
private long lastUpdated;
/**
* Creates a new skill rating with default values.
*
* @param playerUuid The player's UUID
*/
public SkillRating(@NotNull UUID playerUuid) {
this.playerUuid = playerUuid;
this.rating = 1.0;
this.confidence = 0.0;
this.sessionsCount = 0;
this.lastUpdated = System.currentTimeMillis();
}
/**
* Creates a skill rating with specific values.
*
* @param playerUuid The player's UUID
* @param rating The skill rating (typically 0.5 to 2.0)
* @param confidence The confidence level (0.0 to 1.0)
* @param sessionsCount Number of sessions used to calculate this rating
*/
public SkillRating(@NotNull UUID playerUuid, double rating, double confidence, int sessionsCount) {
this.playerUuid = playerUuid;
this.rating = clampRating(rating);
this.confidence = clampConfidence(confidence);
this.sessionsCount = sessionsCount;
this.lastUpdated = System.currentTimeMillis();
}
/**
* Updates the skill rating based on performance.
*
* @param performanceScore Performance score from recent session (0.0 to 2.0)
* @param weight Weight of this update (0.0 to 1.0)
*/
public void updateRating(double performanceScore, double weight) {
rating = (rating * (1.0 - weight)) + (performanceScore * weight);
rating = clampRating(rating);
sessionsCount++;
confidence = Math.min(1.0, confidence + 0.05);
lastUpdated = System.currentTimeMillis();
}
/**
* Gets the effective rating adjusted by confidence.
* Low confidence ratings are pulled toward 1.0 (neutral).
*
* @return Confidence-adjusted rating
*/
public double getEffectiveRating() {
return (rating * confidence) + (1.0 * (1.0 - confidence));
}
/**
* Checks if this rating is reliable enough for adaptive difficulty.
*
* @return True if confidence is above threshold
*/
public boolean isReliable() {
return confidence >= 0.3 && sessionsCount >= 3;
}
private double clampRating(double value) {
return Math.max(0.5, Math.min(2.0, value));
}
private double clampConfidence(double value) {
return Math.max(0.0, Math.min(1.0, value));
}
@NotNull
public UUID getPlayerUuid() {
return playerUuid;
}
public double getRating() {
return rating;
}
public double getConfidence() {
return confidence;
}
public int getSessionsCount() {
return sessionsCount;
}
public long getLastUpdated() {
return lastUpdated;
}
public void setRating(double rating) {
this.rating = clampRating(rating);
}
public void setConfidence(double confidence) {
this.confidence = clampConfidence(confidence);
}
public void setSessionsCount(int sessionsCount) {
this.sessionsCount = sessionsCount;
}
public void setLastUpdated(long lastUpdated) {
this.lastUpdated = lastUpdated;
}
@Override
public String toString() {
return String.format("SkillRating{rating=%.2f, confidence=%.2f, sessions=%d}",
rating, confidence, sessionsCount);
}
}

View file

@ -0,0 +1,185 @@
package dev.loki.loparkour.adaptive.storage;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.adaptive.model.SkillRating;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
/**
* JSON file-based implementation of StatsRepository.
* Stores data in playerdata/<uuid>.json files.
* Saves only when player leaves to minimize I/O.
*/
public class FileStatsStorage implements StatsRepository {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final File dataFolder;
public FileStatsStorage() {
this.dataFolder = new File(LoParkour.getPlugin().getDataFolder(), "playerdata");
if (!dataFolder.exists()) {
dataFolder.mkdirs();
}
}
@Override
@Nullable
public SkillRating loadSkillRating(@NotNull UUID playerUuid) {
JsonObject data = loadPlayerData(playerUuid);
if (data == null || !data.has("skillRating")) {
return null;
}
JsonObject ratingObj = data.getAsJsonObject("skillRating");
double rating = ratingObj.get("rating").getAsDouble();
double confidence = ratingObj.get("confidence").getAsDouble();
int sessions = ratingObj.get("sessionsCount").getAsInt();
return new SkillRating(playerUuid, rating, confidence, sessions);
}
@Override
public void saveSkillRating(@NotNull SkillRating rating) {
JsonObject data = loadPlayerData(rating.getPlayerUuid());
if (data == null) {
data = new JsonObject();
}
JsonObject ratingObj = new JsonObject();
ratingObj.addProperty("rating", rating.getRating());
ratingObj.addProperty("confidence", rating.getConfidence());
ratingObj.addProperty("sessionsCount", rating.getSessionsCount());
ratingObj.addProperty("lastUpdated", rating.getLastUpdated());
data.add("skillRating", ratingObj);
savePlayerData(rating.getPlayerUuid(), data);
}
@Override
@Nullable
public PlayerMetrics loadMetrics(@NotNull UUID playerUuid) {
JsonObject data = loadPlayerData(playerUuid);
if (data == null || !data.has("metrics")) {
return null;
}
JsonObject metricsObj = data.getAsJsonObject("metrics");
PlayerMetrics metrics = new PlayerMetrics(playerUuid);
metrics.setAvgTimePerBlock(metricsObj.get("avgTimePerBlock").getAsDouble());
metrics.setNearMissCount(metricsObj.get("nearMissCount").getAsInt());
metrics.setTotalJumps(metricsObj.get("totalJumps").getAsInt());
metrics.setLastUpdated(metricsObj.get("lastUpdated").getAsLong());
JsonObject jumpStats = metricsObj.getAsJsonObject("jumpTypeStats");
for (Map.Entry<String, com.google.gson.JsonElement> entry : jumpStats.entrySet()) {
metrics.putJumpTypeStat(entry.getKey(), entry.getValue().getAsInt());
}
return metrics;
}
@Override
public void saveMetrics(@NotNull PlayerMetrics metrics) {
JsonObject data = loadPlayerData(metrics.getPlayerUuid());
if (data == null) {
data = new JsonObject();
}
JsonObject metricsObj = new JsonObject();
metricsObj.addProperty("avgTimePerBlock", metrics.getAvgTimePerBlock());
metricsObj.addProperty("nearMissCount", metrics.getNearMissCount());
metricsObj.addProperty("totalJumps", metrics.getTotalJumps());
metricsObj.addProperty("lastUpdated", metrics.getLastUpdated());
JsonObject jumpStats = new JsonObject();
for (Map.Entry<String, Integer> entry : metrics.getJumpTypeStats().entrySet()) {
jumpStats.addProperty(entry.getKey(), entry.getValue());
}
metricsObj.add("jumpTypeStats", jumpStats);
data.add("metrics", metricsObj);
savePlayerData(metrics.getPlayerUuid(), data);
}
@Override
public void incrementJumps(@NotNull UUID playerUuid, int amount) {
JsonObject data = loadPlayerData(playerUuid);
if (data == null) {
data = new JsonObject();
}
int current = data.has("totalJumps") ? data.get("totalJumps").getAsInt() : 0;
data.addProperty("totalJumps", current + amount);
savePlayerData(playerUuid, data);
}
@Override
public void incrementFalls(@NotNull UUID playerUuid, int amount) {
JsonObject data = loadPlayerData(playerUuid);
if (data == null) {
data = new JsonObject();
}
int current = data.has("totalFalls") ? data.get("totalFalls").getAsInt() : 0;
data.addProperty("totalFalls", current + amount);
savePlayerData(playerUuid, data);
}
@Override
public void updateLongestStreak(@NotNull UUID playerUuid, int streak) {
JsonObject data = loadPlayerData(playerUuid);
if (data == null) {
data = new JsonObject();
}
int current = data.has("longestStreak") ? data.get("longestStreak").getAsInt() : 0;
if (streak > current) {
data.addProperty("longestStreak", streak);
savePlayerData(playerUuid, data);
}
}
@Override
public void close() {
// No resources to close for file storage
}
@Nullable
private JsonObject loadPlayerData(@NotNull UUID playerUuid) {
File file = new File(dataFolder, playerUuid.toString() + ".json");
if (!file.exists()) {
return null;
}
try (FileReader reader = new FileReader(file)) {
return GSON.fromJson(reader, JsonObject.class);
} catch (IOException e) {
LoParkour.getPlugin().getLogger().warning(
"Failed to load player data for " + playerUuid + ": " + e.getMessage());
return null;
}
}
private void savePlayerData(@NotNull UUID playerUuid, @NotNull JsonObject data) {
File file = new File(dataFolder, playerUuid.toString() + ".json");
try (FileWriter writer = new FileWriter(file)) {
GSON.toJson(data, writer);
} catch (IOException e) {
LoParkour.getPlugin().getLogger().warning(
"Failed to save player data for " + playerUuid + ": " + e.getMessage());
}
}
}

View file

@ -0,0 +1,60 @@
package dev.loki.loparkour.adaptive.storage;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.adaptive.model.SkillRating;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* No-op implementation of StatsRepository.
* Used as a placeholder until the full storage implementation is ready.
*
* This allows MetricsCollector to function without throwing errors,
* but metrics will not be persisted between server restarts.
*/
public class NoOpStatsRepository implements StatsRepository {
@Override
@Nullable
public SkillRating loadSkillRating(@NotNull UUID playerUuid) {
return null;
}
@Override
public void saveSkillRating(@NotNull SkillRating rating) {
// No-op
}
@Override
@Nullable
public PlayerMetrics loadMetrics(@NotNull UUID playerUuid) {
return null;
}
@Override
public void saveMetrics(@NotNull PlayerMetrics metrics) {
// No-op
}
@Override
public void incrementJumps(@NotNull UUID playerUuid, int amount) {
// No-op
}
@Override
public void incrementFalls(@NotNull UUID playerUuid, int amount) {
// No-op
}
@Override
public void updateLongestStreak(@NotNull UUID playerUuid, int streak) {
// No-op
}
@Override
public void close() {
// No-op
}
}

View file

@ -0,0 +1,173 @@
package dev.loki.loparkour.adaptive.storage;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.adaptive.model.SkillRating;
import dev.loki.loparkour.storage.sql.SQLConnectionManager;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* SQL-based implementation of StatsRepository using loparkour_player_stats table.
* Uses batch updates for optimization.
*/
public class SQLStatsStorage implements StatsRepository {
private final SQLConnectionManager connectionManager;
private final ConcurrentHashMap<UUID, PendingUpdate> pendingUpdates;
private static final int BATCH_SIZE = 50;
public SQLStatsStorage(@NotNull SQLConnectionManager connectionManager) {
this.connectionManager = connectionManager;
this.pendingUpdates = new ConcurrentHashMap<>();
}
@Override
@Nullable
public SkillRating loadSkillRating(@NotNull UUID playerUuid) {
String query = "SELECT skill_rating, sessions_count FROM loparkour_player_stats WHERE player_uuid = ?";
try (Connection conn = connectionManager.getConnection();
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);
}
} catch (SQLException e) {
LoParkour.getPlugin().getLogger().warning("Failed to load skill rating: " + e.getMessage());
}
return null;
}
@Override
public void saveSkillRating(@NotNull SkillRating rating) {
String query = "INSERT INTO loparkour_player_stats (player_uuid, skill_rating, sessions_count) " +
"VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE skill_rating = ?, sessions_count = ?";
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, rating.getPlayerUuid().toString());
stmt.setDouble(2, rating.getRating());
stmt.setInt(3, rating.getSessionsCount());
stmt.setDouble(4, rating.getRating());
stmt.setInt(5, rating.getSessionsCount());
stmt.executeUpdate();
} catch (SQLException e) {
LoParkour.getPlugin().getLogger().warning("Failed to save skill rating: " + e.getMessage());
}
}
@Override
@Nullable
public PlayerMetrics loadMetrics(@NotNull UUID playerUuid) {
// Metrics are stored in JSON files, not SQL
return null;
}
@Override
public void saveMetrics(@NotNull PlayerMetrics metrics) {
// Metrics are stored in JSON files, not SQL
}
@Override
public void incrementJumps(@NotNull UUID playerUuid, int amount) {
PendingUpdate update = pendingUpdates.computeIfAbsent(playerUuid, k -> new PendingUpdate());
update.jumps += amount;
if (pendingUpdates.size() >= BATCH_SIZE) {
flushBatch();
}
}
@Override
public void incrementFalls(@NotNull UUID playerUuid, int amount) {
PendingUpdate update = pendingUpdates.computeIfAbsent(playerUuid, k -> new PendingUpdate());
update.falls += amount;
if (pendingUpdates.size() >= BATCH_SIZE) {
flushBatch();
}
}
@Override
public void updateLongestStreak(@NotNull UUID playerUuid, int streak) {
PendingUpdate update = pendingUpdates.computeIfAbsent(playerUuid, k -> new PendingUpdate());
update.longestStreak = Math.max(update.longestStreak, streak);
if (pendingUpdates.size() >= BATCH_SIZE) {
flushBatch();
}
}
/**
* Flushes all pending updates to database using batch operations.
*/
public void flushBatch() {
if (pendingUpdates.isEmpty()) {
return;
}
String query = "INSERT INTO loparkour_player_stats (player_uuid, total_jumps, total_falls, longest_streak) " +
"VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE " +
"total_jumps = total_jumps + VALUES(total_jumps), " +
"total_falls = total_falls + VALUES(total_falls), " +
"longest_streak = GREATEST(longest_streak, VALUES(longest_streak))";
try (Connection conn = connectionManager.getConnection();
PreparedStatement stmt = conn.prepareStatement(query)) {
List<UUID> processed = new ArrayList<>();
for (var entry : pendingUpdates.entrySet()) {
UUID uuid = entry.getKey();
PendingUpdate update = entry.getValue();
stmt.setString(1, uuid.toString());
stmt.setInt(2, update.jumps);
stmt.setInt(3, update.falls);
stmt.setInt(4, update.longestStreak);
stmt.addBatch();
processed.add(uuid);
}
stmt.executeBatch();
for (UUID uuid : processed) {
pendingUpdates.remove(uuid);
}
} catch (SQLException e) {
LoParkour.getPlugin().getLogger().warning("Failed to flush batch updates: " + e.getMessage());
}
}
@Override
public void close() {
flushBatch();
}
private static class PendingUpdate {
int jumps = 0;
int falls = 0;
int longestStreak = 0;
}
}

View file

@ -0,0 +1,76 @@
package dev.loki.loparkour.adaptive.storage;
import dev.loki.loparkour.adaptive.model.PlayerMetrics;
import dev.loki.loparkour.adaptive.model.SkillRating;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* Repository interface for storing and retrieving player statistics.
* Implementations can use SQL or file-based storage.
*/
public interface StatsRepository {
/**
* Loads skill rating for a player.
*
* @param playerUuid The player's UUID
* @return SkillRating or null if not found
*/
@Nullable
SkillRating loadSkillRating(@NotNull UUID playerUuid);
/**
* Saves skill rating for a player.
*
* @param rating The skill rating to save
*/
void saveSkillRating(@NotNull SkillRating rating);
/**
* Loads detailed metrics for a player.
*
* @param playerUuid The player's UUID
* @return PlayerMetrics or null if not found
*/
@Nullable
PlayerMetrics loadMetrics(@NotNull UUID playerUuid);
/**
* Saves detailed metrics for a player.
*
* @param metrics The metrics to save
*/
void saveMetrics(@NotNull PlayerMetrics metrics);
/**
* Increments total jumps counter for a player.
*
* @param playerUuid The player's UUID
* @param amount Amount to increment
*/
void incrementJumps(@NotNull UUID playerUuid, int amount);
/**
* Increments total falls counter for a player.
*
* @param playerUuid The player's UUID
* @param amount Amount to increment
*/
void incrementFalls(@NotNull UUID playerUuid, int amount);
/**
* Updates longest streak for a player if new streak is longer.
*
* @param playerUuid The player's UUID
* @param streak The new streak value
*/
void updateLongestStreak(@NotNull UUID playerUuid, int streak);
/**
* Closes the repository and releases resources.
*/
void close();
}

View file

@ -1,8 +1,8 @@
package dev.loki.loparkour.api;
package dev.loki.loparkour.api.core;
import dev.loki.loparkour.api.event.ParkourBlockGenerateEvent;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
@ -14,7 +14,7 @@ import org.jetbrains.annotations.Nullable;
* <li>For player spectating, please view {@link dev.efnilite.LoParkour.player.ParkourSpectator}.</li>
* <li>For events, please view {@link ParkourBlockGenerateEvent} and others in the events package.</li>
* <li>For Sessions, please view {@link dev.efnilite.LoParkour.session.Session}.</li>
* <li>For Schematics, please view {@link dev.efnilite.LoParkour.schematic.LPSchematic}</li>
* <li>For jump structures, see {@link dev.loki.loparkour.schematic.core.ParkourSchematic}</li>
* </ul>
*/
public class ParkourAPI {

View file

@ -1,7 +1,7 @@
package dev.loki.loparkour.api;
package dev.loki.loparkour.api.core;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.style.Style;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.style.core.Style;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

View file

@ -1,7 +1,7 @@
package dev.loki.loparkour.api.event;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;

View file

@ -1,6 +1,6 @@
package dev.loki.loparkour.api.event;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;

View file

@ -1,6 +1,6 @@
package dev.loki.loparkour.api.event;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.player.core.ParkourUser;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;

View file

@ -1,6 +1,6 @@
package dev.loki.loparkour.api.event;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.player.core.ParkourUser;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;

View file

@ -1,27 +1,24 @@
package dev.loki.loparkour.api.event;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.schematic.core.ParkourSchematic;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
/**
* Gets called when a new jump is generated. Read-only.
*
* @author loki
* @since 5.0.0
* Gets called when a schematic jump is pasted. Read-only.
*/
public class ParkourSchematicGenerateEvent extends Event {
private static final HandlerList HANDLERS = new HandlerList();
public final LPSchematic schematic;
public final ParkourSchematic schematic;
public final ParkourGenerator generator;
public final ParkourPlayer player;
public ParkourSchematicGenerateEvent(LPSchematic schematic, ParkourGenerator generator, ParkourPlayer player) {
public ParkourSchematicGenerateEvent(ParkourSchematic schematic, ParkourGenerator generator, ParkourPlayer player) {
this.schematic = schematic;
this.generator = generator;
this.player = player;

View file

@ -1,6 +1,6 @@
package dev.loki.loparkour.api.event;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;

View file

@ -1,6 +1,6 @@
package dev.loki.loparkour.api.event;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.player.spectator.ParkourSpectator;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;

View file

@ -0,0 +1,133 @@
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.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.hook.holo.HoloHook;
import dev.loki.loparkour.hook.papi.PAPIHook;
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.ElytraMode;
import dev.loki.loparkour.mode.impl.GravityShiftMode;
import dev.loki.loparkour.mode.impl.HardcoreMode;
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.reward.core.Rewards;
import dev.loki.loparkour.schematic.core.SchematicManager;
import dev.loki.loparkour.world.core.World;
import org.bstats.bukkit.Metrics;
import org.bstats.charts.SimplePie;
import org.bstats.charts.SingleLineChart;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Wires plugin subsystems during {@link LoParkour#enable()} and {@link LoParkour#disable()}.
*/
public final class PluginBootstrap {
private PluginBootstrap() {
}
public static void enable(@NotNull LoParkour plugin) {
Config.reload(true);
AdaptiveServices.init(plugin);
loadSchematics(plugin);
registerModes();
registerHooks(plugin);
setupWorld(plugin);
registerEventsAndCommands(plugin);
setupMetrics(plugin);
}
public static void disable(@NotNull LoParkour plugin) {
plugin.runShutdownSequence();
}
private static void loadSchematics(@NotNull LoParkour plugin) {
extractSchematicDocs(plugin);
SchematicManager manager = new SchematicManager();
manager.loadAll();
plugin.setSchematicManager(manager);
}
private static void extractSchematicDocs(@NotNull LoParkour plugin) {
java.io.File doc = LoParkour.getInFolder("docs/SCHEMATICS.md");
if (!doc.exists()) {
doc.getParentFile().mkdirs();
plugin.saveResource("docs/SCHEMATICS.md", false);
}
}
private static void registerModes() {
Registry.register(new DefaultMode());
Registry.register(new SpectatorMode());
Registry.register(new SpeedrunMode());
Registry.register(new ElytraMode());
Registry.register(new RaceMode());
Registry.register(new CoopMode());
Registry.register(new GravityShiftMode());
Registry.register(new HardcoreMode());
Modes.init();
}
private static void registerHooks(@NotNull LoParkour plugin) {
if (plugin.getServer().getPluginManager().isPluginEnabled("HolographicDisplays")) {
plugin.getLogger().info("Registered Holographic Displays hook");
HoloHook.init();
}
if (plugin.getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) {
plugin.getLogger().info("Registered PlaceholderAPI hook");
PAPIHook hook = new PAPIHook();
hook.register();
plugin.setPlaceholderHook(hook);
}
if (Config.CONFIG.getBoolean("bungeecord.enabled")) {
plugin.getServer().getMessenger().registerOutgoingPluginChannel(plugin, "BungeeCord");
plugin.getLogger().info("Registered BungeeCord hook");
}
}
private static void setupWorld(@NotNull LoParkour plugin) {
if (!Config.CONFIG.getBoolean("joining")) {
return;
}
try {
plugin.loLogger().info("Creating parkour world...");
World.create();
plugin.loLogger().info("Parkour world created successfully!");
} catch (Exception ex) {
plugin.loLogger().error("Failed to create parkour world", ex);
}
}
private static void registerEventsAndCommands(@NotNull LoParkour plugin) {
dev.lolib.gui.GUIManager.init(plugin);
var pm = plugin.getServer().getPluginManager();
pm.registerEvents(new PlayerConnectionListener(), plugin);
pm.registerEvents(new ParkourRestrictionListener(), plugin);
pm.registerEvents(new SchematicWandListener(), plugin);
LoParkourCommand cmd = new LoParkourCommand();
plugin.getCommand("LoParkour").setExecutor(cmd);
plugin.getCommand("LoParkour").setTabCompleter(cmd);
}
private static void setupMetrics(@NotNull LoParkour plugin) {
Metrics metrics = new Metrics(plugin, 29754);
metrics.addCustomChart(new SimplePie("using_sql", () -> Boolean.toString(Option.SQL)));
metrics.addCustomChart(new SimplePie("using_rewards", () -> Boolean.toString(Rewards.REWARDS_ENABLED)));
metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.getLocaleCount())));
metrics.addCustomChart(new SingleLineChart("player_joins", () ->
dev.loki.loparkour.player.service.UserRegistry.getJoinCount()));
}
}

View file

@ -1,22 +1,23 @@
package dev.loki.loparkour;
package dev.loki.loparkour.command;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.command.AdminCommandHandler;
import dev.loki.loparkour.command.PlayerCommandHandler;
import dev.loki.loparkour.command.SchematicCommandHandler;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.mode.MultiMode;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.ColorUtil;
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.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
@ -38,6 +39,11 @@ import java.util.logging.Level;
*/
public class LoParkourCommand implements CommandExecutor, TabCompleter {
private static final List<String> SCHEMATIC_DIFFICULTIES = List.of("0.0", "0.25", "0.5", "0.75", "1.0");
private static final List<String> SCHEMATIC_SUBCOMMANDS = List.of(
"wand", "pos1", "pos2", "create", "convert", "paste", "list", "reload"
);
private final PlayerCommandHandler player = new PlayerCommandHandler();
private final AdminCommandHandler admin = new AdminCommandHandler(player);
@ -52,13 +58,14 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
case 1 -> player.handle(args[0], sender, p);
case 2 -> handle2(args[0], args[1], sender, p);
case 3 -> handle3(args[0], args[1], args[2], sender, p);
default -> sender.sendMessage(ChatColor.GRAY + "Too many arguments. Try /parkour help");
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);
sender.sendMessage(ChatColor.RED + "LoParkour: command error — see server console.");
Locales.send(sender, "commands.error");
return true;
}
}
@ -76,6 +83,11 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
if (p == null) return;
if (a1.equalsIgnoreCase("create") && 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);
@ -93,9 +105,22 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
private void handle3(String a1, String a2, String a3, CommandSender sender, @Nullable Player p) {
if (p == null) return;
if (!a1.equalsIgnoreCase("schematic")) return;
if (!p.hasPermission(ParkourOption.ADMIN.permission)) return;
SchematicCommandHandler.handleSubcommandWithName(a2, a3, sender, p, player);
if (a1.equalsIgnoreCase("schematic")) {
if (a2.equalsIgnoreCase("create")) {
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 (!a1.equalsIgnoreCase("schematic") || !a2.equalsIgnoreCase("create")) return;
SchematicCommandHandler.handleCreateWithDifficulty(a3, a4, sender, p, player);
}
// ── join logic ─────────────────────────────────────────────────────────────
@ -107,10 +132,10 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
if (mode != null) { mode.create(p); return; }
Player other = Bukkit.getPlayer(arg);
if (other == null) { send(sender, LoParkour.PREFIX + "Unknown player."); return; }
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 + "That player isn't playing."); return; }
if (pp == null) { send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.not_playing")); return; }
ParkourUser user = ParkourUser.getUser(p);
Session session = pp.session;
@ -156,7 +181,7 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
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", "reload", "forcejoin", "forceleave", "reset", "recoverinventory"));
completions.addAll(List.of("schematic", "create", "reload", "forcejoin", "forceleave", "reset", "recoverinventory"));
}
return filter(args[0], completions);
}
@ -172,7 +197,9 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
} else if (a1.equals("leaderboard") && ParkourOption.LEADERBOARDS.mayPerform(sender)) {
Registry.getModes().forEach(m -> completions.add(m.getName()));
} else if (a1.equals("schematic") && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(Arrays.asList("wand", "pos1", "pos2", "save", "paste", "list", "reload"));
completions.addAll(SCHEMATIC_SUBCOMMANDS);
} else if (a1.equals("create") && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
} else if ((a1.equals("forcejoin") || a1.equals("forceleave")) && sender.hasPermission(ParkourOption.ADMIN.permission)) {
completions.add("everyone");
if (a1.equals("forcejoin")) completions.add("nearest");
@ -183,9 +210,45 @@ public class LoParkourCommand implements CommandExecutor, TabCompleter {
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 (a1.equals("schematic")) {
if (a2.equals("paste")) {
addLoadedSchematicIds(completions);
} else if (a2.equals("create")) {
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 (a1.equals("schematic") && a2.equals("create")) {
completions.addAll(SCHEMATIC_DIFFICULTIES);
}
return filter(args[3], completions);
}
return Collections.emptyList();
}
private static void addLoadedSchematicIds(@NotNull List<String> completions) {
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return;
}
for (String id : manager.getAll().keySet()) {
completions.add(id);
String shortId = SchematicManager.configKey(id);
if (!shortId.equalsIgnoreCase(id)) {
completions.add(shortId);
}
}
}
// ── util ───────────────────────────────────────────────────────────────────
private List<String> filter(String input, List<String> options) {

View file

@ -1,172 +0,0 @@
package dev.loki.loparkour.command;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import dev.loki.loparkour.schematic.lpschem.LPSchematicBuilder;
import dev.loki.loparkour.util.ColorUtil;
import dev.loki.loparkour.util.Locations;
import dev.loki.loparkour.util.ParticleUtil;
import org.bukkit.Location;
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.Material;
import org.bukkit.util.BoundingBox;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Handles /LoParkour schematic sub-commands (wand, pos1, pos2, save, paste, list, reload).
*/
public class SchematicCommandHandler {
public static final Map<Player, Location[]> selections = new HashMap<>();
private static final ItemStack WAND = buildWand();
private static ItemStack buildWand() {
ItemStack item = new ItemStack(Material.GOLDEN_AXE);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(ColorUtil.color("<red><bold>LPSchematic Wand"));
meta.setLore(List.of(
ColorUtil.color("<gray>Left click: first position"),
ColorUtil.color("<gray>Right click: second position")
));
item.setItemMeta(meta);
}
return item;
}
public static ItemStack getWand() {
return WAND.clone();
}
public static void sendHelp(Player player) {
send(player, "");
send(player, "<dark_red><bold>Schematics <reset><dark_gray>-----------");
send(player, "<red>/LoParkour schematic wand <dark_gray>- <gray>Get the wand");
send(player, "<red>/LoParkour schematic pos1 <dark_gray>- <gray>Set position 1");
send(player, "<red>/LoParkour schematic pos2 <dark_gray>- <gray>Set position 2");
send(player, "<red>/LoParkour schematic save <n> <dark_gray>- <gray>Save selection");
send(player, "<red>/LoParkour schematic paste <n> <dark_gray>- <gray>Paste schematic");
send(player, "<red>/LoParkour schematic list <dark_gray>- <gray>List loaded schematics");
send(player, "<red>/LoParkour schematic reload <dark_gray>- <gray>Reload schematics");
send(player, "");
}
/** Called for 2-arg schematic commands (wand/pos1/pos2/list/reload) */
public static void handleSubcommand(String sub, CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
Location playerLoc = player.getLocation();
Location[] sel = selections.get(player);
switch (sub.toLowerCase()) {
case "wand" -> {
player.getInventory().addItem(WAND.clone());
send(player, "<dark_red><bold>Schematics <reset><gray>Wand given. Left/Right click to set positions.");
}
case "pos1" -> setPos(player, sel, playerLoc, 0);
case "pos2" -> setPos(player, sel, playerLoc, 1);
case "list" -> {
send(player, "<dark_red><bold>Schematics <reset><gray>Loaded schematics:");
LoParkour.getSchematicManager().getAllSchematics().forEach((name, s) ->
send(player, "<gray>- <red>" + name + " <dark_gray>(" + s.getMetadata().getDifficulty() + ")"));
}
case "reload" -> {
if (!cooldowns.cooldown(sender, "schematic-reload", 2500)) return;
LoParkour.getSchematicManager().reload();
send(player, "<dark_red><bold>Schematics <reset><gray>Reloaded.");
}
}
}
/** Called for 3-arg schematic commands (save <n>, paste <n>) */
public static void handleSubcommandWithName(String sub, String name, CommandSender sender,
Player player, PlayerCommandHandler cooldowns) {
switch (sub.toLowerCase()) {
case "save" -> handleSave(name, sender, player, cooldowns);
case "paste" -> handlePaste(name, sender, player);
}
}
// ── internal ───────────────────────────────────────────────────────────────
private static void setPos(Player player, Location[] existing, Location loc, int index) {
String label = index == 0 ? "1" : "2";
send(player, LoParkour.PREFIX + "Position " + label + " set to " + Locations.toString(loc, true));
Location[] updated = existing != null ? existing.clone() : new Location[]{null, null};
updated[index] = loc;
selections.put(player, updated);
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 handleSave(String name, CommandSender sender, Player player, PlayerCommandHandler cooldowns) {
if (!cooldowns.cooldown(sender, "schematic-save", 2500)) return;
// Validate schematic name to prevent path traversal
if (!isValidSchematicName(name)) {
send(player, "<dark_red><bold>Schematics <reset><red>Invalid name. Use only letters, numbers, hyphens and underscores.");
return;
}
Location[] sel = selections.get(player);
if (sel == null || sel[0] == null || sel[1] == null) {
send(player, "<dark_red><bold>Schematics <reset><red>Selection incomplete. Set both positions first.");
return;
}
try {
int w = Math.abs(sel[1].getBlockX() - sel[0].getBlockX()) + 1;
int h = Math.abs(sel[1].getBlockY() - sel[0].getBlockY()) + 1;
int l = Math.abs(sel[1].getBlockZ() - sel[0].getBlockZ()) + 1;
LPSchematic schematic = new LPSchematicBuilder(name, player.getName(), 0.5, w, h, l)
.fromWorld(player.getWorld(), sel[0], sel[1])
.build();
LoParkour.getSchematicManager().saveSchematic(schematic);
send(player, "<dark_red><bold>Schematics <reset><gray>Saved as <red>" + name + "<gray>!");
} catch (Exception e) {
send(player, "<dark_red><bold>Schematics <reset><red>Failed: " + e.getMessage());
}
}
/**
* Validates schematic name to prevent path traversal and filesystem issues.
* Only allows alphanumeric characters, hyphens, and underscores.
*/
private static boolean isValidSchematicName(String name) {
if (name == null || name.isEmpty() || name.length() > 64) {
return false;
}
return name.matches("^[a-zA-Z0-9_-]+$");
}
private static void handlePaste(String name, CommandSender sender, Player player) {
// Validate schematic name to prevent path traversal
if (!isValidSchematicName(name)) {
send(player, "<dark_red><bold>Schematics <reset><red>Invalid name. Use only letters, numbers, hyphens and underscores.");
return;
}
LPSchematic s = LoParkour.getSchematicManager().getSchematic(name);
if (s == null) {
send(sender, LoParkour.PREFIX + "Schematic '" + name + "' not found.");
return;
}
s.paste(player.getLocation(), player.getWorld());
send(sender, LoParkour.PREFIX + "Pasted " + name + ".");
}
private static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -1,15 +1,17 @@
package dev.loki.loparkour.command;
package dev.loki.loparkour.command.admin;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
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.ColorUtil;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
@ -27,8 +29,6 @@ import java.util.UUID;
*/
public class AdminCommandHandler {
private static final String COOLDOWN_HINT = "<gray>Please wait before using this again.";
private final PlayerCommandHandler base;
public AdminCommandHandler(PlayerCommandHandler base) {
@ -61,18 +61,18 @@ public class AdminCommandHandler {
if (target.equalsIgnoreCase("everyone")) {
Bukkit.getOnlinePlayers().forEach(p -> Modes.DEFAULT.create(p));
send(sender, LoParkour.PREFIX + "Force joined everyone!");
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_join_everyone"));
return;
}
if (target.equalsIgnoreCase("nearest")) {
Player closest = findNearest(sender);
if (closest == null) return;
Modes.DEFAULT.create(closest);
send(sender, LoParkour.PREFIX + "Force joined " + closest.getName() + "!");
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 + "Player not online!"); return; }
if (other == null) { send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.player_not_online")); return; }
Modes.DEFAULT.create(other);
}
@ -83,27 +83,27 @@ public class AdminCommandHandler {
if (target.equalsIgnoreCase("everyone")) {
ParkourPlayer.getPlayers().forEach(ParkourUser::leave);
send(sender, LoParkour.PREFIX + "Force kicked everyone!");
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.force_leave_everyone"));
return;
}
Player other = Bukkit.getPlayer(target);
if (other == null) { send(sender, LoParkour.PREFIX + "Player not online!"); return; }
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 + "Player isn't playing!"); return; }
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, COOLDOWN_HINT)) return;
if (!base.cooldown(sender, "reset", 2500, Locales.getString(sender, "admin.cooldown"))) return;
if (target.equalsIgnoreCase("everyone")) {
Registry.getModes().stream()
.map(Mode::getLeaderboard)
.filter(lb -> lb != null)
.forEach(lb -> { lb.resetAll(); lb.write(true); });
send(sender, LoParkour.PREFIX + "Reset all high scores.");
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_all"));
return;
}
@ -115,7 +115,7 @@ public class AdminCommandHandler {
.filter(lb -> lb != null)
.forEach(lb -> { lb.remove(uuid); lb.write(true); });
send(sender, LoParkour.PREFIX + "Reset high score of " + name + ".");
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.reset_player").formatted(name));
}
// ── recoverinventory ───────────────────────────────────────────────────────
@ -123,13 +123,13 @@ public class AdminCommandHandler {
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 + "Player not online!"); return; }
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 + "Recovered inventory of " + other.getName() + ".");
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.recover_inventory").formatted(other.getName()));
} else {
send(sender, LoParkour.PREFIX + "<red>No saved inventory found for " + other.getName() + ".");
send(sender, LoParkour.PREFIX + Locales.getString(sender, "admin.recover_inventory_missing").formatted(other.getName()));
}
});
}

View file

@ -1,13 +1,14 @@
package dev.loki.loparkour.command;
package dev.loki.loparkour.command.player;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.util.ColorUtil;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.util.text.ColorUtil;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
@ -80,34 +81,38 @@ public class PlayerCommandHandler {
private void handleReload(@NotNull CommandSender sender, @Nullable Player player) {
if (!cooldown(sender, "reload", 2500)) return;
if (!sender.hasPermission(ParkourOption.ADMIN.permission)) {
send(sender, Locales.getString(player, "other.no_do"));
send(sender, Locales.getString(sender, "other.no_do"));
return;
}
Config.reload(false);
send(sender, LoParkour.PREFIX + "Reloading config files... (locales are loading asynchronously)");
dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.reload();
send(sender, LoParkour.PREFIX + Locales.getString(sender, "commands.reload"));
}
public void sendHelp(@NotNull CommandSender sender) {
send(sender, "");
send(sender, "<dark_gray><strikethrough>---------------<reset> " + LoParkour.NAME + " <dark_gray><strikethrough>---------------<reset>");
send(sender, Locales.getString(sender, "commands.help.header"));
send(sender, "");
send(sender, "<gray>/parkour <dark_gray>- Main command");
send(sender, Locales.getString(sender, "commands.help.line_parkour"));
if (sender.hasPermission(ParkourOption.JOIN.permission)) {
send(sender, "<gray>/parkour join [mode/player] <dark_gray>- Join a mode");
send(sender, "<gray>/parkour leave <dark_gray>- Leave parkour");
send(sender, Locales.getString(sender, "commands.help.line_join"));
send(sender, Locales.getString(sender, "commands.help.line_leave"));
}
if (sender.hasPermission(ParkourOption.MAIN.permission)) {
send(sender, Locales.getString(sender, "commands.help.line_menu"));
}
if (sender.hasPermission(ParkourOption.PLAY.permission)) {
send(sender, Locales.getString(sender, "commands.help.line_play"));
}
if (sender.hasPermission(ParkourOption.LEADERBOARDS.permission)) {
send(sender, Locales.getString(sender, "commands.help.line_leaderboard"));
}
if (sender.hasPermission(ParkourOption.MAIN.permission))
send(sender, "<gray>/parkour menu <dark_gray>- Open the menu");
if (sender.hasPermission(ParkourOption.PLAY.permission))
send(sender, "<gray>/parkour play <dark_gray>- Mode selection menu");
if (sender.hasPermission(ParkourOption.LEADERBOARDS.permission))
send(sender, "<gray>/parkour leaderboard [type] <dark_gray>- Leaderboard");
if (sender.hasPermission(ParkourOption.ADMIN.permission)) {
send(sender, "<gray>/LoParkour schematic <dark_gray>- Schematic tools");
send(sender, "<gray>/LoParkour reload <dark_gray>- Reload configs");
send(sender, "<gray>/LoParkour reset <everyone/player> <dark_gray>- Reset scores");
send(sender, "<gray>/LoParkour forcejoin/forceleave <target> <dark_gray>- Force join/leave");
send(sender, "<gray>/LoParkour recoverinventory <player> <dark_gray>- Recover inventory");
send(sender, Locales.getString(sender, "commands.help.line_schematic"));
send(sender, Locales.getString(sender, "commands.help.line_reload"));
send(sender, Locales.getString(sender, "commands.help.line_reset"));
send(sender, Locales.getString(sender, "commands.help.line_force"));
send(sender, Locales.getString(sender, "commands.help.line_recover"));
}
send(sender, "");
}
@ -117,7 +122,7 @@ public class PlayerCommandHandler {
}
/**
* @param whenBlocked message sent (MiniMessage color) if still on cooldown; may be null for silent block
* @param whenBlocked message sent ({@code &#RRGGBB} / {@code &} colors) if still on cooldown; may be null for silent block
*/
public boolean cooldown(CommandSender sender, String key, long millis, @Nullable String whenBlocked) {
String fullKey = sender.getName() + ":" + key;

View file

@ -0,0 +1,271 @@
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.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;
/**
* Admin schematic tools: wand, create, convert, list, paste, reload.
*/
public class SchematicCommandHandler {
public static final Map<Player, Location[]> selections = new HashMap<>();
public static final NamespacedKey WAND_KEY = new NamespacedKey(LoParkour.getPlugin(), "schematic_wand");
private static ItemStack cachedWand;
@NotNull
public static ItemStack getWand() {
return buildWand().clone();
}
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);
}
@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;
}
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);
}
public static void clearWandCache() {
cachedWand = null;
}
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, "");
}
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);
}
}
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 -> {
}
}
}
/** {@code schematic create <name> <difficulty>} */
public static void handleCreateWithDifficulty(
String name,
String difficultyRaw,
CommandSender sender,
Player player,
PlayerCommandHandler cooldowns
) {
if (!cooldowns.cooldown(sender, "schematic-create", 2500)) {
return;
}
handleCreate(name, difficultyRaw, sender, player);
}
/** {@code create <difficulty>} or {@code schematic create <difficulty>} */
public static void handleCreateDifficultyOnly(
String difficultyRaw,
CommandSender sender,
Player player,
PlayerCommandHandler cooldowns
) {
if (!cooldowns.cooldown(sender, "schematic-create", 2500)) {
return;
}
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_-]+$");
}
@NotNull
private static ItemStack buildWand() {
if (cachedWand != null) {
return cachedWand;
}
String locale = Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
if (locale == null || locale.isBlank()) {
locale = "en";
}
ItemStack item = Locales.getItem(locale, "schematic.wand").build();
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.getPersistentDataContainer().set(WAND_KEY, PersistentDataType.BYTE, (byte) 1);
item.setItemMeta(meta);
}
cachedWand = item;
return item;
}
private static void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.config;
package dev.loki.loparkour.config.core;
import org.bukkit.configuration.file.FileConfiguration;
import org.jetbrains.annotations.NotNull;
@ -116,4 +116,4 @@ public enum Config {
public List<String> getChildren(@NotNull String path, boolean... deep) {
return accessor.getChildren(path, deep);
}
}
}

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.config;
package dev.loki.loparkour.config.core;
import dev.loki.loparkour.LoParkour;
import org.bukkit.configuration.ConfigurationSection;
@ -144,4 +144,4 @@ public class ConfigAccessor {
"Missing config path '" + path + "' in " + fileName + ". Using a safe default value.");
}
}
}
}

View file

@ -1,5 +1,7 @@
package dev.loki.loparkour.config;
package dev.loki.loparkour.config.core;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.LoParkour;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
@ -55,9 +57,9 @@ public class ConfigLoader {
}
public static void initializeDependentSystems(boolean firstLoad) {
dev.loki.loparkour.reward.Rewards.init();
dev.loki.loparkour.reward.core.Rewards.init();
Locales.init();
dev.loki.loparkour.schematic.Schematics.init();
dev.loki.loparkour.schematic.registry.Schematics.init();
Option.init(firstLoad);
}

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.config;
package dev.loki.loparkour.config.core;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.config;
package dev.loki.loparkour.config.locale;
import org.bukkit.configuration.file.FileConfiguration;
import org.jetbrains.annotations.NotNull;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.config;
package dev.loki.loparkour.config.locale;
import dev.loki.loparkour.LoParkour;
import org.bukkit.configuration.ConfigurationSection;

View file

@ -1,10 +1,12 @@
package dev.loki.loparkour.config;
package dev.loki.loparkour.config.locale;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.util.Item;
import dev.loki.loparkour.util.Materials;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.util.text.ColorUtil;
import dev.loki.loparkour.util.item.Item;
import dev.loki.loparkour.util.item.Materials;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.FileConfiguration;
@ -29,6 +31,7 @@ public class Locales {
public static void init() {
Map<String, FileConfiguration> loaded = LocaleLoader.loadAll();
cache.setLocales(loaded);
dev.loki.loparkour.command.schematic.SchematicCommandHandler.clearWandCache();
LoParkour.log("Locales loaded successfully (" + loaded.size() + " locales)");
}
@ -61,6 +64,24 @@ public class Locales {
return cache.cachedValue(locale, c -> c.getString(path), "");
}
/**
* Sends a colored message from locale. Safe for console ({@link #getString(CommandSender, String)}).
*/
public static void send(@NotNull CommandSender sender, @NotNull String path, Object... format) {
String message = getString(sender, path);
if (format.length > 0) {
message = message.formatted(format);
}
sender.sendMessage(ColorUtil.color(message));
}
/**
* Sends a colored message using the player's locale.
*/
public static void send(@NotNull Player player, @NotNull String path, Object... format) {
send((CommandSender) player, path, format);
}
@NotNull
public static List<String> getStringList(@NotNull String locale, @NotNull String path) {
return cache.cachedValue(locale, c -> c.getStringList(path), Collections.emptyList());
@ -107,11 +128,9 @@ public class Locales {
String name = applyReplacements(config.getString(namePath, ""), replace);
String lore = applyReplacements(config.getString(lorePath, ""), replace);
// Debug logging
if ("STONE".equals(material)) {
LoParkour.getPlugin().getLogger().warning("Material defaulted to STONE for path: " + path);
LoParkour.getPlugin().getLogger().warning(" Tried: " + materialPath + " = " + config.getString(materialPath));
LoParkour.getPlugin().getLogger().warning(" Available keys: " + config.getKeys(true));
if ("STONE".equals(material) && !config.contains(materialPath)) {
LoParkour.getPlugin().getLogger().warning(
"Missing locale key '%s' (material defaults to STONE)".formatted(materialPath));
}
Material mat = Materials.parse(material);

View file

@ -1,11 +1,12 @@
package dev.loki.loparkour.config;
package dev.loki.loparkour.config.options;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.options.*;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.style.RandomStyle;
import dev.loki.loparkour.style.Style;
import dev.loki.loparkour.util.ParticleData;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.style.core.RandomStyle;
import dev.loki.loparkour.style.core.Style;
import dev.loki.loparkour.util.particle.ParticleData;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;

View file

@ -1,8 +1,8 @@
package dev.loki.loparkour.config.options;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.menu.core.ParkourOption;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;

View file

@ -1,7 +1,7 @@
package dev.loki.loparkour.config.options;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.core.Config;
import java.util.HashMap;
import java.util.Map;

View file

@ -1,9 +1,9 @@
package dev.loki.loparkour.config.options;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.util.ParticleData;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.util.particle.ParticleData;
import org.bukkit.Particle;
import org.bukkit.Sound;

View file

@ -1,7 +1,7 @@
package dev.loki.loparkour.config.options;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.core.Config;
/**
* SQL database configuration options.

View file

@ -1,9 +1,9 @@
package dev.loki.loparkour.config.options;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.ConfigAccessor;
import dev.loki.loparkour.style.Style;
import dev.loki.loparkour.util.Materials;
import dev.loki.loparkour.config.core.ConfigAccessor;
import dev.loki.loparkour.style.core.Style;
import dev.loki.loparkour.util.item.Materials;
import org.bukkit.Material;
import org.bukkit.configuration.file.FileConfiguration;

View file

@ -0,0 +1,53 @@
package dev.loki.loparkour.generator.core.coordinator;
import dev.loki.loparkour.generator.jump.calculation.JumpType;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
/**
* Interface for listening to generator events.
* Allows adaptive system to integrate without creating direct dependencies.
*
* This decouples the generator from the adaptive system:
* - Generator fires events through this interface
* - Adaptive system implements this interface to collect metrics
* - No circular dependencies between generator and adaptive packages
*/
public interface GeneratorEventListener {
/**
* Called when a block is generated.
*
* @param player The player for whom the block was generated
* @param block The generated block
* @param jumpType The type of jump required to reach this block
* @param distance The distance from the previous block
*/
void onBlockGenerated(@NotNull Player player, @NotNull Block block, @NotNull JumpType jumpType, double distance);
/**
* Called when a player successfully lands on a block and scores.
*
* @param player The player who scored
* @param block The block the player landed on
* @param distance The distance of the jump
*/
void onPlayerScore(@NotNull Player player, @NotNull Block block, double distance);
/**
* Called when a player falls off the parkour.
*
* @param player The player who fell
* @param lastBlock The last block the player was on (may be null)
*/
void onPlayerFall(@NotNull Player player, Block lastBlock);
/**
* Called when a player performs a jump.
*
* @param player The player who jumped
* @param jumpType The type of jump performed
*/
void onJump(@NotNull Player player, @NotNull JumpType jumpType);
}

View file

@ -1,6 +1,9 @@
package dev.loki.loparkour.generator;
package dev.loki.loparkour.generator.core.coordinator;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.generator.core.model.Profile;
import dev.loki.loparkour.generator.profile.GenerationProfileLoader;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
@ -10,22 +13,31 @@ import java.util.Map;
* Manages generator profiles and difficulty calculations.
*/
public class GeneratorProfileManager {
private final ParkourGenerator generator;
public GeneratorProfileManager(@NotNull ParkourGenerator generator) {
this.generator = generator;
}
/**
* Override profile settings based on generator options.
* Rebuilds profile from config, player settings, and adaptive difficulty when enabled.
*/
public void overrideProfile() {
// Generator options are for disabling features, not difficulty
// Difficulty is controlled by profile settings
// This method can be used to apply custom profile overrides if needed
Profile rebuilt = GenerationProfileLoader.loadFromConfig();
GenerationProfileLoader.preservePlayerSettings(generator.profile, rebuilt);
AdaptiveServices.applyAdaptiveProfile(generator, rebuilt);
GenerationProfileLoader.copyInto(generator.profile, rebuilt);
}
/**
* Recalculates chances after a fall or settings change.
*/
public void recalculateProfile() {
overrideProfile();
calculateChances();
}
/**
* Calculate generation chances based on current profile.
*/
@ -35,112 +47,113 @@ public class GeneratorProfileManager {
calculateDefaultChances();
calculateSpecialChances();
}
/**
* Calculate difficulty score based on current settings.
*/
public double getDifficultyScore() {
double score = 0.0;
// Distance difficulty
for (Map.Entry<Integer, Double> entry : generator.state.distanceChances.entrySet()) {
score += entry.getKey() * entry.getValue() * 0.3;
}
// Height difficulty
for (Map.Entry<Integer, Double> entry : generator.state.heightChances.entrySet()) {
score += Math.abs(entry.getKey()) * entry.getValue() * 0.2;
}
// Special blocks difficulty
for (Map.Entry<BlockData, Double> entry : generator.state.specialChances.entrySet()) {
score += entry.getValue() * 0.5;
}
return Math.round(score * 100.0) / 100.0;
}
private void calculateDistanceChances() {
generator.state.distanceChances.clear();
// Get distance range from config
int minDistance = Config.GENERATION.getInt("advanced.distance.min", 1);
int maxDistance = Config.GENERATION.getInt("advanced.distance.max", 4);
int minDistance = profileInt("distance.min", "advanced.distance.min", 1);
int maxDistance = profileInt("distance.max", "advanced.distance.max", 4);
for (int distance = minDistance; distance <= maxDistance; distance++) {
int chance = Config.GENERATION.getInt("advanced.distance.chances." + distance, 1);
int chance = profileInt(
"distance.chances." + distance,
"advanced.distance.chances." + distance,
1);
generator.state.distanceChances.put(distance, (double) chance);
}
// Normalize chances to sum to 1.0
normalizeMap(generator.state.distanceChances);
}
private void calculateHeightChances() {
generator.state.heightChances.clear();
// Get height range from config
int minHeight = Config.GENERATION.getInt("advanced.height.min", -1);
int maxHeight = Config.GENERATION.getInt("advanced.height.max", 2);
int minHeight = profileInt("height.min", "advanced.height.min", -1);
int maxHeight = profileInt("height.max", "advanced.height.max", 2);
for (int height = minHeight; height <= maxHeight; height++) {
int chance = Config.GENERATION.getInt("advanced.height.chances." + height, 1);
int chance = profileInt(
"height.chances." + height,
"advanced.height.chances." + height,
1);
generator.state.heightChances.put(height, (double) chance);
}
// Normalize chances to sum to 1.0
normalizeMap(generator.state.heightChances);
}
private void calculateDefaultChances() {
generator.state.defaultChances.clear();
int defaultChance = Config.GENERATION.getInt("advanced.type.default", 80);
int specialChance = Config.GENERATION.getInt("advanced.type.special", 15);
int schematicChance = Config.GENERATION.getInt("advanced.type.schematic", 5);
int defaultChance = profileInt("type.default", "advanced.type.default", 80);
int specialChance = profileInt("type.special", "advanced.type.special", 15);
int schematicChance = profileInt("type.schematic", "advanced.type.schematic", 5);
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.DEFAULT, (double) defaultChance);
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SPECIAL, (double) specialChance);
generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SCHEMATIC, (double) schematicChance);
// Normalize chances to sum to 1.0
normalizeMap(generator.state.defaultChances);
}
private void calculateSpecialChances() {
generator.state.specialChances.clear();
try {
for (String specialType : Config.GENERATION.getChildren("advanced.special")) {
String materialPath = "advanced.special." + specialType + ".material";
String chancePath = "advanced.special." + specialType + ".chance";
String materialName = Config.GENERATION.getString(materialPath);
int chance = Config.GENERATION.getInt(chancePath, 1);
BlockData blockData = org.bukkit.Bukkit.createBlockData(materialName);
generator.state.specialChances.put(blockData, (double) chance);
}
// Normalize chances to sum to 1.0
normalizeMap(generator.state.specialChances);
} catch (Exception e) {
// If special blocks config is missing, just skip
} catch (Exception ignored) {
// special blocks config optional
}
}
/**
* Normalize a map so all values sum to 1.0
* If sum is 0, distributes values equally.
*/
private int profileInt(@NotNull String profileKey, @NotNull String configKey, int defaultValue) {
if (generator.profile.settings.containsKey(profileKey)) {
return generator.profile.get(profileKey).asInt();
}
return Config.GENERATION.getInt(configKey, defaultValue);
}
private <K> void normalizeMap(Map<K, Double> map) {
if (map.isEmpty()) return;
if (map.isEmpty()) {
return;
}
double sum = map.values().stream().mapToDouble(Double::doubleValue).sum();
if (sum > 0) {
map.replaceAll((k, v) -> v / sum);
} else {
// If all values are 0, distribute equally
double equalValue = 1.0 / map.size();
map.replaceAll((k, v) -> equalValue);
}

View file

@ -1,10 +1,10 @@
package dev.loki.loparkour.generator;
package dev.loki.loparkour.generator.core.coordinator;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.config.locale.Locales;
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.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
@ -94,4 +94,4 @@ public class GeneratorStatistics {
return formatter.format(date);
}
}
}

View file

@ -1,19 +1,23 @@
package dev.loki.loparkour.generator;
package dev.loki.loparkour.generator.core.coordinator;
import dev.loki.loparkour.generator.jump.BlockPlacer;
import dev.loki.loparkour.generator.lifecycle.GeneratorLifecycle;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.generator.core.model.GeneratorOption;
import dev.loki.loparkour.generator.core.model.GeneratorState;
import dev.loki.loparkour.generator.core.model.Island;
import dev.loki.loparkour.generator.core.model.Profile;
import dev.loki.loparkour.generator.jump.placement.BlockPlacer;
import dev.loki.loparkour.generator.lifecycle.loop.GeneratorLifecycle;
import dev.loki.loparkour.menu.core.Menus;
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.session.core.Session;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
/**
@ -32,37 +36,38 @@ public class ParkourGenerator {
protected final GeneratorLifecycle lifecycle;
public final GeneratorProfileManager profileManager;
private final GeneratorStatistics statistics;
// Event listeners for adaptive system integration
private final List<GeneratorEventListener> eventListeners;
public ParkourGenerator(@NotNull Session session, @Nullable LPSchematic schematic, GeneratorOption... generatorOptions) {
public ParkourGenerator(@NotNull Session session, GeneratorOption... generatorOptions) {
this.session = session;
this.generatorOptions = generatorOptions;
this.island = new Island(session, schematic);
this.island = new Island(session);
this.profile = new Profile();
this.state = new GeneratorState();
this.eventListeners = new ArrayList<>();
// Initialize zone from session
if (session != null) {
Location[] selection = dev.loki.loparkour.world.Divider.toSelection(session);
Location[] selection = dev.loki.loparkour.world.core.Divider.toSelection(session);
if (selection == null || selection.length < 2) {
throw new IllegalArgumentException("Invalid session zone: selection must contain at least 2 locations");
}
this.state.zone = selection;
}
// Initialize components
this.placer = new BlockPlacer(this);
this.lifecycle = new GeneratorLifecycle(this);
this.profileManager = new GeneratorProfileManager(this);
this.statistics = new GeneratorStatistics(this);
// Setup initial state
profileManager.overrideProfile();
profileManager.calculateChances();
}
public ParkourGenerator(@NotNull Session session, GeneratorOption... generatorOptions) {
this(session, null, generatorOptions);
}
// Core generation methods
public void generate() { placer.generate(); }
@ -125,8 +130,41 @@ public class ParkourGenerator {
if (state.history.isEmpty()) return null;
return state.history.get(state.history.size() - 1);
}
// Event listener management
/**
* Register an event listener for generator events.
* Used by adaptive system to collect metrics without creating direct dependencies.
*
* @param listener The listener to register
*/
public void registerEventListener(@NotNull GeneratorEventListener listener) {
if (!eventListeners.contains(listener)) {
eventListeners.add(listener);
}
}
/**
* Unregister an event listener.
*
* @param listener The listener to unregister
*/
public void unregisterEventListener(@NotNull GeneratorEventListener listener) {
eventListeners.remove(listener);
}
/**
* Get all registered event listeners.
*
* @return Immutable list of event listeners
*/
@NotNull
public List<GeneratorEventListener> getEventListeners() {
return new ArrayList<>(eventListeners);
}
public enum BlockGenerationType {
DEFAULT, SPECIAL, SCHEMATIC
}
}
}

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.generator;
package dev.loki.loparkour.generator.core.model;
/**
* Options for {@link ParkourGenerator}s.

View file

@ -1,5 +1,6 @@
package dev.loki.loparkour.generator;
package dev.loki.loparkour.generator.core.model;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;

View file

@ -1,9 +1,9 @@
package dev.loki.loparkour.generator;
package dev.loki.loparkour.generator.core.model;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.Materials;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.item.Materials;
import org.bukkit.Location;
import org.bukkit.Material;
@ -12,8 +12,6 @@ import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
@ -26,16 +24,11 @@ public final class Island {
private static final int PLATFORM_RADIUS = 4;
public final Session session;
/**
* Unused for spawn; kept for API / {@link dev.loki.loparkour.generator.jump.BlockPlacer} schematic branch.
*/
public final dev.loki.loparkour.schematic.lpschem.LPSchematic schematic;
public List<Block> blocks;
public Island(@NotNull Session session, @Nullable dev.loki.loparkour.schematic.lpschem.LPSchematic schematic) {
public Island(@NotNull Session session) {
this.session = session;
this.schematic = schematic;
}
public void build(Location location) {

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.generator;
package dev.loki.loparkour.generator.core.model;
import org.jetbrains.annotations.NotNull;

View file

@ -1,9 +1,9 @@
package dev.loki.loparkour.generator.effect;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.util.ParticleUtil;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.util.particle.ParticleUtil;
import org.bukkit.Location;
import org.bukkit.block.Block;

View file

@ -1,8 +1,9 @@
package dev.loki.loparkour.generator.jump;
package dev.loki.loparkour.generator.jump.calculation;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.GeneratorOption;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.generator.core.model.GeneratorOption;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.jump.placement.BlockSelector;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
@ -162,4 +163,4 @@ public class JumpCalculator {
this.maxDistance = maxDistance;
}
}
}
}

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.generator.jump;
package dev.loki.loparkour.generator.jump.calculation;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;

View file

@ -1,6 +1,6 @@
package dev.loki.loparkour.generator.jump;
package dev.loki.loparkour.generator.jump.calculation;
import dev.loki.loparkour.util.Probs;
import dev.loki.loparkour.util.misc.Probs;
import java.util.Map;
import java.util.Random;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.generator.jump;
package dev.loki.loparkour.generator.jump.calculation;
import org.bukkit.Material;
import org.jetbrains.annotations.NotNull;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.generator.jump;
package dev.loki.loparkour.generator.jump.calculation;
import org.bukkit.Location;
import org.bukkit.util.Vector;

View file

@ -1,9 +1,13 @@
package dev.loki.loparkour.generator.jump;
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import dev.loki.loparkour.util.Probs;
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.Particle;
import org.bukkit.Sound;
@ -154,31 +158,38 @@ public class BlockPlacer {
}
private boolean tryGenerateSchematic() {
// Simplified schematic generation
LPSchematic schematic = generator.island.schematic; // Получаем схематик из island
if (schematic == null) return false;
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) return false;
Block targetBlock = blocks.get(0);
Location pasteLocation = targetBlock.getLocation();
// Paste schematic (simplified)
List<Block> schematicBlocks = pasteSchematic(schematic, pasteLocation);
if (!schematicBlocks.isEmpty()) {
generator.state.history.addAll(schematicBlocks);
generator.state.schematicBlocks = schematicBlocks;
return true;
SchematicManager manager = LoParkour.getSchematicManager();
if (manager == null) {
return false;
}
return false;
double maxDifficulty = generator.getPlayers().stream()
.mapToDouble(p -> p.schematicDifficulty)
.min()
.orElse(1.0);
ParkourSchematic schematic = manager.pick(maxDifficulty);
if (schematic == null) {
return false;
}
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) {
return false;
}
Location pasteLocation = blocks.get(0).getLocation();
List<Block> schematicBlocks = schematic.paste(pasteLocation, pasteLocation.getWorld());
if (schematicBlocks.isEmpty()) {
return false;
}
generator.state.history.addAll(schematicBlocks);
generator.state.schematicBlocks = schematicBlocks;
for (ParkourPlayer player : generator.getPlayers()) {
new ParkourSchematicGenerateEvent(schematic, generator, player).call();
}
return true;
}
@NotNull
private List<Block> pasteSchematic(@NotNull LPSchematic schematic, @NotNull Location location) {
// Use the schematic's built-in paste method
return schematic.paste(location, location.getWorld());
}
}
}

View file

@ -1,8 +1,9 @@
package dev.loki.loparkour.generator.jump;
package dev.loki.loparkour.generator.jump.placement;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.style.Style;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.jump.calculation.JumpType;
import dev.loki.loparkour.style.core.Style;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.jetbrains.annotations.NotNull;
@ -138,4 +139,4 @@ public class BlockSelector {
material == Material.WARPED_TRAPDOOR ||
material == Material.IRON_TRAPDOOR;
}
}
}

View file

@ -1,6 +1,8 @@
package dev.loki.loparkour.generator.lifecycle;
package dev.loki.loparkour.generator.lifecycle.loop;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.lifecycle.player.GeneratorCleanup;
import dev.loki.loparkour.generator.lifecycle.player.PlayerInteractionHandler;
import org.jetbrains.annotations.NotNull;
/**

View file

@ -1,12 +1,12 @@
package dev.loki.loparkour.generator.lifecycle;
package dev.loki.loparkour.generator.lifecycle.loop;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.reward.Reward;
import dev.loki.loparkour.reward.Rewards;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.reward.core.Reward;
import dev.loki.loparkour.reward.core.Rewards;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
@ -104,4 +104,4 @@ public class LifecycleEventManager {
// Reset generator state
generator.reset(true);
}
}
}

View file

@ -1,7 +1,8 @@
package dev.loki.loparkour.generator.lifecycle;
package dev.loki.loparkour.generator.lifecycle.loop;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.lifecycle.player.GeneratorCleanup;
import org.jetbrains.annotations.NotNull;
/**

View file

@ -1,7 +1,7 @@
package dev.loki.loparkour.generator.lifecycle;
package dev.loki.loparkour.generator.lifecycle.loop;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
/**

View file

@ -1,8 +1,8 @@
package dev.loki.loparkour.generator.lifecycle;
package dev.loki.loparkour.generator.lifecycle.player;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.config.options.Option;
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.block.Block;
@ -115,7 +115,7 @@ public class GeneratorCleanup {
Location blockSpawn = spawn.clone();
// Move block forward based on heading (6 blocks forward, same height)
switch (dev.loki.loparkour.config.Option.HEADING) {
switch (dev.loki.loparkour.config.options.Option.HEADING) {
case NORTH -> blockSpawn.add(0, 0, -6);
case SOUTH -> blockSpawn.add(0, 0, 6);
case WEST -> blockSpawn.add(-6, 0, 0);
@ -127,4 +127,4 @@ public class GeneratorCleanup {
}
}
}
}

View file

@ -1,8 +1,10 @@
package dev.loki.loparkour.generator.lifecycle;
package dev.loki.loparkour.generator.lifecycle.player;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.generator.core.coordinator.GeneratorEventListener;
import dev.loki.loparkour.generator.lifecycle.loop.LifecycleEventManager;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
@ -47,7 +49,18 @@ public class PlayerInteractionHandler {
public void handleScore() {
generator.state.score++;
eventManager.handleScore();
// Fire event listeners for adaptive system
Block latestBlock = generator.getLatest();
if (latestBlock != null) {
for (ParkourPlayer player : generator.getPlayers()) {
double distance = calculateDistance(player, latestBlock);
for (GeneratorEventListener listener : generator.getEventListeners()) {
listener.onPlayerScore(player.player, latestBlock, distance);
}
}
}
// Generate more blocks after scoring
generator.generate(1);
}
@ -56,7 +69,20 @@ public class PlayerInteractionHandler {
* Handle player falling.
*/
public void handleFall() {
// Fire event listeners for adaptive system
Block latestBlock = generator.getLatest();
for (ParkourPlayer player : generator.getPlayers()) {
for (GeneratorEventListener listener : generator.getEventListeners()) {
listener.onPlayerFall(player.player, latestBlock);
}
}
eventManager.handleFall();
for (ParkourPlayer player : generator.getPlayers()) {
AdaptiveServices.persistRatingAfterFall(player);
}
AdaptiveServices.refreshGeneratorDifficulty(generator);
}
private void handleBlockInteraction(@NotNull ParkourPlayer player, @NotNull Block block) {
@ -84,12 +110,8 @@ public class PlayerInteractionHandler {
}
private boolean isSchematicEndBlock(@NotNull Block block) {
LPSchematic schematic = generator.island.schematic;
if (schematic == null) return false;
// Simplified check - would need full schematic logic
return generator.state.schematicBlocks != null &&
generator.state.schematicBlocks.contains(block);
return generator.state.schematicBlocks != null
&& generator.state.schematicBlocks.contains(block);
}
/** Block directly under feet; {@code null} in air — scoring runs when landed. */
@ -100,4 +122,13 @@ public class PlayerInteractionHandler {
return below.getType() != Material.AIR ? below : null;
}
/**
* Calculate distance between player and block.
*/
private double calculateDistance(@NotNull ParkourPlayer player, @NotNull Block block) {
Location playerLoc = player.player.getLocation();
Location blockLoc = block.getLocation().add(0.5, 0, 0.5);
return playerLoc.distance(blockLoc);
}
}

View file

@ -0,0 +1,68 @@
package dev.loki.loparkour.generator.profile;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.generator.core.model.Profile;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Set;
/**
* Loads default generation weights from generation.yml into a {@link Profile}.
*/
public final class GenerationProfileLoader {
private static final Set<String> PLAYER_KEYS = Set.of(
"schematicDifficulty", "blockLead", "particles", "sound",
"useSpecialBlocks", "showFallMessage", "showScoreboard",
"selectedTime", "style"
);
private GenerationProfileLoader() {
}
@NotNull
public static Profile loadFromConfig() {
Profile profile = new Profile();
int minDistance = Config.GENERATION.getInt("advanced.distance.min", 1);
int maxDistance = Config.GENERATION.getInt("advanced.distance.max", 4);
profile.set("distance.min", String.valueOf(minDistance));
profile.set("distance.max", String.valueOf(maxDistance));
for (int distance = minDistance; distance <= maxDistance; distance++) {
int chance = Config.GENERATION.getInt("advanced.distance.chances." + distance, 1);
profile.set("distance.chances." + distance, String.valueOf(chance));
}
int minHeight = Config.GENERATION.getInt("advanced.height.min", -1);
int maxHeight = Config.GENERATION.getInt("advanced.height.max", 2);
profile.set("height.min", String.valueOf(minHeight));
profile.set("height.max", String.valueOf(maxHeight));
for (int height = minHeight; height <= maxHeight; height++) {
int chance = Config.GENERATION.getInt("advanced.height.chances." + height, 1);
profile.set("height.chances." + height, String.valueOf(chance));
}
profile.set("type.default", String.valueOf(Config.GENERATION.getInt("advanced.type.default", 80)));
profile.set("type.special", String.valueOf(Config.GENERATION.getInt("advanced.type.special", 15)));
profile.set("type.schematic", String.valueOf(Config.GENERATION.getInt("advanced.type.schematic", 5)));
return profile;
}
public static void preservePlayerSettings(@NotNull Profile from, @NotNull Profile to) {
for (String key : PLAYER_KEYS) {
if (from.settings.containsKey(key)) {
to.set(key, from.get(key).value());
}
}
}
/** Replaces all settings on {@code target} with a snapshot from {@code source}. */
public static void copyInto(@NotNull Profile target, @NotNull Profile source) {
target.settings.clear();
for (Map.Entry<String, Profile.ProfileValue> entry : source.settings.entrySet()) {
target.set(entry.getKey(), entry.getValue().value());
}
}
}

View file

@ -1,7 +1,9 @@
package dev.loki.loparkour.ghost;
package dev.loki.loparkour.ghost.core;
import dev.loki.loparkour.ghost.core.GhostPlayer;
import dev.loki.loparkour.ghost.model.GhostData;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.core.Config;
import org.bukkit.Location;
import org.bukkit.World;
import org.jetbrains.annotations.NotNull;

View file

@ -1,4 +1,6 @@
package dev.loki.loparkour.ghost;
package dev.loki.loparkour.ghost.core;
import dev.loki.loparkour.ghost.model.GhostFrame;
import dev.loki.loparkour.ghost.model.GhostData;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;

View file

@ -1,4 +1,6 @@
package dev.loki.loparkour.ghost;
package dev.loki.loparkour.ghost.core;
import dev.loki.loparkour.ghost.model.GhostFrame;
import dev.loki.loparkour.ghost.model.GhostData;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.ghost;
package dev.loki.loparkour.ghost.model;
import org.jetbrains.annotations.NotNull;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.ghost;
package dev.loki.loparkour.ghost.model;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.hook;
package dev.loki.loparkour.hook.floodgate;
import org.bukkit.entity.Player;
import org.geysermc.floodgate.api.FloodgateApi;

View file

@ -1,10 +1,10 @@
package dev.loki.loparkour.hook;
package dev.loki.loparkour.hook.holo;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.Mode;
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 me.filoghost.holographicdisplays.api.HolographicDisplaysAPI;
public class HoloHook {
@ -94,4 +94,4 @@ public class HoloHook {
}
return "?";
}
}
}

View file

@ -1,15 +1,15 @@
package dev.loki.loparkour.hook;
package dev.loki.loparkour.hook.papi;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.player.ParkourUser;
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;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.hook;
package dev.loki.loparkour.hook.vault;
import dev.loki.loparkour.LoParkour;
import net.milkbowl.vault.economy.Economy;

View file

@ -1,4 +1,6 @@
package dev.loki.loparkour.leaderboard;
package dev.loki.loparkour.leaderboard.core;
import dev.loki.loparkour.leaderboard.persistence.LeaderboardStorage;
import dev.loki.loparkour.leaderboard.model.Score;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

View file

@ -1,4 +1,5 @@
package dev.loki.loparkour.leaderboard;
package dev.loki.loparkour.leaderboard.core;
import dev.loki.loparkour.leaderboard.model.Score;
import org.jetbrains.annotations.NotNull;

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.leaderboard;
package dev.loki.loparkour.leaderboard.model;
/**
* Represents a record, used to keep track of the score a player may achieve.

View file

@ -1,7 +1,10 @@
package dev.loki.loparkour.leaderboard;
package dev.loki.loparkour.leaderboard.persistence;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.leaderboard.core.LeaderboardSorter;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.storage.Storage;
import dev.lolib.scheduler.Scheduler;
import org.jetbrains.annotations.NotNull;

View file

@ -1,57 +0,0 @@
package dev.loki.loparkour.listener;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.SchematicCommandHandler;
import dev.loki.loparkour.util.ColorUtil;
import dev.loki.loparkour.util.Locations;
import dev.loki.loparkour.util.ParticleUtil;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
/**
* Handles LPSchematic wand clicks (left/right click on blocks).
*/
public class SchematicWandListener implements Listener {
@EventHandler
public void onWandInteract(PlayerInteractEvent event) {
Player player = event.getPlayer();
ItemStack item = player.getInventory().getItemInMainHand();
if (!player.hasPermission("LoParkour.admin")) return;
if (item.getItemMeta() == null || !item.getItemMeta().getDisplayName().contains("LPSchematic Wand")) return;
if (event.getClickedBlock() == null || event.getHand() != EquipmentSlot.HAND) return;
Location loc = event.getClickedBlock().getLocation();
Location[] sel = SchematicCommandHandler.selections.get(player);
event.setCancelled(true);
if (event.getAction() == Action.LEFT_CLICK_BLOCK) {
Location[] updated = new Location[]{loc, sel != null ? sel[1] : null};
SchematicCommandHandler.selections.put(player, updated);
send(player, LoParkour.PREFIX + "Position 1 set to " + Locations.toString(loc, true));
if (updated[1] != null)
ParticleUtil.box(BoundingBox.of(loc, updated[1]), player.getWorld(), Particle.END_ROD, player, 0.2);
} else if (event.getAction() == Action.RIGHT_CLICK_BLOCK) {
Location[] updated = new Location[]{sel != null ? sel[0] : null, loc};
SchematicCommandHandler.selections.put(player, updated);
send(player, LoParkour.PREFIX + "Position 2 set to " + Locations.toString(loc, true));
if (updated[0] != null)
ParticleUtil.box(BoundingBox.of(updated[0], loc), player.getWorld(), Particle.END_ROD, player, 0.2);
}
}
private void send(CommandSender sender, String msg) {
sender.sendMessage(ColorUtil.color(msg));
}
}

View file

@ -1,13 +1,13 @@
package dev.loki.loparkour.listener;
package dev.loki.loparkour.listener.gameplay;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session;
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.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.player.core.ParkourPlayer;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.session.core.Session;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;

View file

@ -1,10 +1,10 @@
package dev.loki.loparkour.listener;
package dev.loki.loparkour.listener.player;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.world.World;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Modes;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.world.core.World;
import io.papermc.lib.PaperLib;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;

View file

@ -0,0 +1,60 @@
package dev.loki.loparkour.listener.schematic;
import dev.loki.loparkour.command.schematic.SchematicCommandHandler;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.util.particle.ParticleUtil;
import dev.loki.loparkour.util.text.ColorUtil;
import dev.loki.loparkour.util.world.Locations;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.jetbrains.annotations.NotNull;
/**
* Wand left/right click sets schematic selection corners.
*/
public class SchematicWandListener implements Listener {
@EventHandler(ignoreCancelled = true)
public void onInteract(@NotNull PlayerInteractEvent event) {
if (event.getHand() != EquipmentSlot.HAND) {
return;
}
if (!SchematicCommandHandler.isWand(event.getItem())) {
return;
}
Action action = event.getAction();
if (action != Action.LEFT_CLICK_BLOCK && action != Action.RIGHT_CLICK_BLOCK) {
return;
}
if (event.getClickedBlock() == null) {
return;
}
event.setCancelled(true);
var player = event.getPlayer();
Location loc = event.getClickedBlock().getLocation();
int index = action == Action.LEFT_CLICK_BLOCK ? 0 : 1;
SchematicCommandHandler.setPos(player, loc, index);
String label = index == 0 ? "1" : "2";
player.sendMessage(ColorUtil.color(
Locales.getString(player, "schematic.pos_set").formatted(label, Locations.toString(loc, true))));
Location[] sel = SchematicCommandHandler.getSelection(player);
if (sel[0] != null && sel[1] != null) {
ParticleUtil.box(
org.bukkit.util.BoundingBox.of(sel[0], sel[1]),
player.getWorld(),
Particle.END_ROD,
player,
0.2
);
}
}
}

View file

@ -1,9 +1,9 @@
package dev.loki.loparkour.menu.community;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.LPMenu;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;

View file

@ -1,12 +1,12 @@
package dev.loki.loparkour.menu.community;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.menu.core.LPMenu;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
@ -64,12 +64,12 @@ public class LeaderboardsMenu extends LPMenu {
// Add navigation buttons
if (currentPage > 0) {
// Previous page button
gui = gui.setItem(0, backItem(), e -> open(player, currentPage - 1));
gui = gui.setItem(0, backItem(player), e -> open(player, currentPage - 1));
}
if (currentPage < totalPages - 1) {
// Next page button
gui = gui.setItem(8, nextItem(), e -> open(player, currentPage + 1));
gui = gui.setItem(8, nextItem(player), e -> open(player, currentPage + 1));
}
gui.open(player);

View file

@ -1,12 +1,12 @@
package dev.loki.loparkour.menu.community;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.leaderboard.core.Leaderboard;
import dev.loki.loparkour.leaderboard.model.Score;
import dev.loki.loparkour.menu.core.LPMenu;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.Material;
import org.bukkit.OfflinePlayer;
@ -89,12 +89,12 @@ public class SingleLeaderboardMenu extends LPMenu {
// Add navigation buttons
if (currentPage > 0) {
// Previous page button
gui = gui.setItem(0, backItem(), e -> open(player, mode, sort, currentPage - 1));
gui = gui.setItem(0, backItem(player), e -> open(player, mode, sort, currentPage - 1));
}
if (currentPage < totalPages - 1) {
// Next page button
gui = gui.setItem(8, nextItem(), e -> open(player, mode, sort, currentPage + 1));
gui = gui.setItem(8, nextItem(player), e -> open(player, mode, sort, currentPage + 1));
}
gui.open(player);

View file

@ -1,6 +1,6 @@
package dev.loki.loparkour.menu;
package dev.loki.loparkour.menu.core;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.player.core.ParkourUser;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -29,7 +29,7 @@ public abstract class DynamicMenu {
*/
// Stub: migrate to LoLib GUI when dynamic rows are wired up.
/*
public void registerMainItem(int row, int id, BiFunction<@NotNull Player, @Nullable ParkourUser, dev.loki.loparkour.util.MenuItem> item, Predicate<Player> shouldDisplay) {
public void registerMainItem(int row, int id, BiFunction<@NotNull Player, @Nullable ParkourUser, dev.loki.loparkour.util.gui.MenuItem> item, Predicate<Player> shouldDisplay) {
if (id < 0 || row < 0 || row > 4) {
return;
}
@ -75,6 +75,6 @@ public abstract class DynamicMenu {
/**
* Data class for registered items
*/
protected record ItemContainer(int id, BiFunction<@NotNull Player, @Nullable ParkourUser, dev.loki.loparkour.util.MenuItem> item,
protected record ItemContainer(int id, BiFunction<@NotNull Player, @Nullable ParkourUser, dev.loki.loparkour.util.gui.MenuItem> item,
Predicate<Player> predicate) {}
}

View file

@ -1,8 +1,8 @@
package dev.loki.loparkour.menu;
package dev.loki.loparkour.menu.core;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.util.ColorUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.config.options.Option;
import dev.loki.loparkour.util.text.ColorUtil;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.Material;
import org.bukkit.entity.Player;
@ -25,7 +25,7 @@ public abstract class LPMenu {
public abstract void open(@NotNull Player player);
protected String locale(@NotNull Player player) {
var user = dev.loki.loparkour.player.ParkourUser.getUser(player);
var user = dev.loki.loparkour.player.core.ParkourUser.getUser(player);
String loc = user != null ? user.locale : Option.OPTIONS_DEFAULTS.get(ParkourOption.LANG);
return (loc != null && !loc.isBlank()) ? loc : "en";
}
@ -40,24 +40,12 @@ public abstract class LPMenu {
return item;
}
protected ItemStack backItem() {
ItemStack item = new ItemStack(Material.RED_DYE);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(ColorUtil.color("<#DE1F1F><bold>Назад"));
item.setItemMeta(meta);
}
return item;
protected ItemStack backItem(@NotNull Player player) {
return localeItem(player, "other.back");
}
protected ItemStack nextItem() {
ItemStack item = new ItemStack(Material.LIME_DYE);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(ColorUtil.color("<#0DCB07><bold>Далее"));
item.setItemMeta(meta);
}
return item;
protected ItemStack nextItem(@NotNull Player player) {
return localeItem(player, "other.next");
}
protected ItemStack closeButton(@NotNull Player player) {

View file

@ -1,8 +1,8 @@
package dev.loki.loparkour.menu;
package dev.loki.loparkour.menu.core;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
@ -59,7 +59,7 @@ public class MainMenu extends LPMenu {
int count = visibleItems.size();
if (count == 0) {
player.sendMessage(Locales.getString(player, "other.no_menu_items"));
Locales.send(player, "other.no_menu_items");
return;
}

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.menu;
package dev.loki.loparkour.menu.core;
/**
* Placeholder during menu migration; real flows use {@link Menus} / LoLib GUI.

View file

@ -1,4 +1,4 @@
package dev.loki.loparkour.menu;
package dev.loki.loparkour.menu.core;
import dev.loki.loparkour.menu.community.CommunityMenu;
import dev.loki.loparkour.menu.community.LeaderboardsMenu;

View file

@ -1,7 +1,7 @@
package dev.loki.loparkour.menu;
package dev.loki.loparkour.menu.core;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.options.Option;
import org.bukkit.permissions.Permissible;
/**

View file

@ -1,12 +1,12 @@
package dev.loki.loparkour.menu.lobby;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.ColorUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.LPMenu;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.loki.loparkour.session.core.Session;
import dev.loki.loparkour.util.text.ColorUtil;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.Material;
import org.bukkit.entity.Player;

View file

@ -1,12 +1,12 @@
package dev.loki.loparkour.menu.lobby;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.util.ColorUtil;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.LPMenu;
import dev.loki.loparkour.menu.core.Menus;
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.util.text.ColorUtil;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.Material;
import org.bukkit.entity.Player;
@ -51,10 +51,18 @@ public class PlayerManagementMenu extends LPMenu {
boolean muted = session.isMuted(other);
ItemMeta displayMeta = skull.getItemMeta();
if (displayMeta != null) {
displayMeta.setDisplayName(ColorUtil.color("<yellow>" + other.getName() + (muted ? " §c[muted]" : "")));
String muteSuffix = muted
? Locales.getString(locale, "lobby.player_management.head.muted_suffix")
: "";
displayMeta.setDisplayName(ColorUtil.color(
Locales.getString(locale, "lobby.player_management.head.name")
.formatted(other.getName(), muteSuffix)));
String muteLoreKey = muted
? "lobby.player_management.head.lore_unmute"
: "lobby.player_management.head.lore_mute";
displayMeta.setLore(List.of(
ColorUtil.color("§7Left-click §fto kick"),
ColorUtil.color("§7Right-click §f" + (muted ? "to unmute" : "to mute"))
ColorUtil.color(Locales.getString(locale, "lobby.player_management.head.lore_kick")),
ColorUtil.color(Locales.getString(locale, muteLoreKey))
));
skull.setItemMeta(displayMeta);
}

View file

@ -1,9 +1,9 @@
package dev.loki.loparkour.menu.play;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.LPMenu;
import dev.loki.loparkour.menu.core.Menus;
import dev.loki.loparkour.menu.core.ParkourOption;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;

View file

@ -1,11 +1,11 @@
package dev.loki.loparkour.menu.play;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.menu.LPMenu;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.api.core.Registry;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.menu.core.LPMenu;
import dev.loki.loparkour.mode.base.Mode;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.lolib.gui.InventoryGUI;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
@ -81,12 +81,12 @@ public class SingleMenu extends LPMenu {
// Add navigation buttons
if (currentPage > 0) {
// Previous page button
gui = gui.setItem(0, backItem(), e -> open(player, currentPage - 1));
gui = gui.setItem(0, backItem(player), e -> open(player, currentPage - 1));
}
if (currentPage < totalPages - 1) {
// Next page button
gui = gui.setItem(8, nextItem(), e -> open(player, currentPage + 1));
gui = gui.setItem(8, nextItem(player), e -> open(player, currentPage + 1));
}
gui.open(player);

Some files were not shown because too many files have changed in this diff Show more