diff --git a/COMMANDS_GUIDE.md b/COMMANDS_GUIDE.md index 1ceb40a..6b94582 100644 --- a/COMMANDS_GUIDE.md +++ b/COMMANDS_GUIDE.md @@ -1,489 +1,489 @@ -# Dead by Daylight Plugin — Руководство команд - -**Версия:** 1.2 -**Обновлено:** Июнь 2026 -**Платформа:** Minecraft 1.21.5 (Paper) - ---- - -## Содержание - -1. [Фазы игры](#фазы-игры) -2. [Команды игроков — /dbd](#команды-игроков--dbd) -3. [Команды администратора — /dbdadmin](#команды-администратора--dbdadmin) -4. [Команды настройки карты — /dbdsetup](#команды-настройки-карты--dbdsetup) -5. [Права доступа](#права-доступа) -6. [Порядок настройки сервера](#порядок-настройки-сервера) -7. [Таблица всех команд](#таблица-всех-команд) - ---- - -## Фазы игры - -``` -LOBBY - Игроки делают /dbd join → телепорт в мир lobby - Action bar показывает: ▶ Старт через: 45с ████████░░ (3/5) - Когда набралось минимум игроков — идёт отсчёт 60 сек - При нехватке игроков — таймер сбрасывается - ↓ -ROLE ASSIGN (5 сек) - Случайно выбирается 1 киллер, остальные — выжившие - Показывается тайтл с ролью - Все телепортируются на свои спавны в мире game - ↓ -GAMEPLAY - Выжившие чинят генераторы (нужно 5 из 7) - Киллер охотится - При починке всех нужных генераторов — открываются ворота - ↓ -ENDGAME (2 минуты) - Выжившие открывают ворота и убегают - Или используют люк (если остался 1 выживший) - Коллапс через 120 сек убивает всех оставшихся -``` - ---- - -## Команды игроков — /dbd - -Все команды ниже доступны любому игроку (`mdbd.player`, по умолчанию `true`). - ---- - -### /dbd join - -Присоединиться к игровой очереди. - -``` -/dbd join -``` - -- Добавляет игрока в текущую сессию как выжившего (роль назначится позже) -- Телепортирует в мир `lobby` -- В action bar начинает отображаться счётчик и таймер -- Если игра не создана — сообщение об ошибке - -``` -→ [DBD] Вы присоединились к игре! -→ [DBD] PlayerName присоединился к очереди! (1/5) -``` - ---- - -### /dbd leave - -Покинуть сессию. - -``` -/dbd leave -``` - -- В лобби: просто убирает из очереди, таймер может сброситься -- В игре: выживший считается мёртвым, киллер завершает игру -- Игрок телепортируется обратно в `lobby` - -``` -→ [DBD] Вы покинули игру -``` - ---- - -### /dbd lobby - -Телепортироваться в лобби-мир. - -``` -/dbd lobby -``` - -- Работает в любой момент, не убирает из сессии -- Телепортирует на точку спавна лобби (из `locations.yml`) или на спавн мира `lobby` - -``` -→ [DBD] Вы телепортированы в лобби! -``` - ---- - -## Команды администратора — /dbd (admin) - -Требуют право `mdbd.admin` (по умолчанию только OP). - ---- - -### /dbd create - -Создать новую игровую сессию. - -``` -/dbd create -``` - -- Создаёт сессию и открывает лобби -- Нельзя создать вторую сессию пока первая активна - -``` -→ [DBD] Игра создана! -→ [DBD] Игра уже существует (если уже есть) -``` - ---- - -### /dbd forcestart - -Принудительно запустить игру, пропустив таймер. - -``` -/dbd forcestart -``` - -- Работает только если сессия в фазе LOBBY -- Игроков в очереди может быть меньше минимума - -``` -→ [DBD] Игра форсирована! -``` - ---- - -### /dbd stop - -Немедленно завершить текущую сессию. - -``` -/dbd stop -``` - -- Все игроки телепортируются в `lobby` -- Сессия уничтожается, можно создавать новую - -``` -→ [DBD] Игра остановлена -``` - ---- - -### /dbd reload - -Перезагрузить все конфиги без перезапуска сервера. - -``` -/dbd reload -``` - -Перезагружает: `config.yml`, `game.yml`, `messages.yml`, `skillcheck.yml`, `locations.yml`, `generators.yml` - -``` -→ [DBD] Конфиги перезагружены! -``` - -> Если игра в процессе — сначала `/dbd stop`, иначе новые настройки применятся только к следующей сессии. - ---- - -### /dbd setlobby - -Установить текущую позицию игрока как точку спавна лобби. - -``` -/dbd setlobby -``` - -- Сохраняется в `locations.yml` -- Эквивалентно `/dbdsetup lobby` - -``` -→ [DBD] Лобби установлено! -``` - ---- - -## Команды администратора — /dbdadmin - -Требуют право `mdbd.admin`. Все команды только для игроков (не консоли). - ---- - -### /dbdadmin create generator - -Режим расстановки генераторов. - -``` -/dbdadmin create generator -``` - -- Включает режим: в action bar оранжевая подсказка -- **Сломай блок** — координаты сохранятся, блок не разрушится -- Повторная команда — отключает режим -- Режим остаётся активным для нескольких генераторов подряд - -``` -→ Режим выбора генератора включен! -→ Сломайте блок, чтобы установить генератор -(сломал блок) -→ Генератор установлен! Всего: 1 -``` - -**Tab complete:** `generator` - ---- - -### /dbdadmin list - -Список всех установленных генераторов. - -``` -/dbdadmin list -``` - -``` -=== Генераторы (3) === -[0] game:100.00,64.00,200.00 -[1] game:150.00,64.00,150.00 -[2] game:200.00,64.00,100.00 -``` - ---- - -### /dbdadmin remove \ - -Удалить генератор по ID из списка. - -``` -/dbdadmin remove 0 -``` - -**Tab complete:** подсказывает существующие ID (0, 1, 2...) - -``` -→ Генератор удален: game:100.00,64.00,200.00 -→ Генератор с ID 5 не найден (если нет такого) -``` - ---- - -### /dbdadmin clear - -Удалить все генераторы. - -``` -/dbdadmin clear -``` - -> Необратимо. - -``` -→ Все генераторы удалены -``` - ---- - -### /dbdadmin world \<мир\> - -Телепортироваться в указанный мир для настройки карты. - -``` -/dbdadmin world lobby -/dbdadmin world game -/dbdadmin world <любое_название_мира> -``` - -- `lobby` — телепорт в мир-лобби (void, платформа 7x7) -- `game` — телепорт в игровой мир -- Любое другое название — телепорт в мир с таким именем (если загружен) - -``` -→ Телепортирован в мир: lobby -→ Мир не найден: abc (если мир не существует) -``` - -**Tab complete:** `lobby`, `game` - ---- - -## Команды настройки карты — /dbdsetup - -Требуют право `mdbd.admin`. - ---- - -### /dbdsetup wand - -Выдать топорик для расстановки ворот. - -``` -/dbdsetup wand -``` - -- Выдаёт деревянный топорик (WOODEN_AXE) с подписью DBD Setup Wand -- **ЛКМ** по блоку = точка 1 (pos1) -- **ПКМ** по блоку = точка 2 (pos2) -- Только для ворот. Для остальных объектов ванд не нужен. - ---- - -### /dbdsetup set \<тип\> - -Войти в режим установки объекта. **Сломай блок** — объект установится на его месте, блок восстановится. - -``` -/dbdsetup set generator -/dbdsetup set hook -/dbdsetup set pallet -/dbdsetup set chest -/dbdsetup set killer-spawn -/dbdsetup set survivor-spawn -``` - -| Тип | Описание | Рекомендуемое кол-во | -|-----|----------|---------------------| -| `generator` | Генератор | 7 | -| `hook` | Крюк | 7–10 | -| `pallet` | Паллет | 15–20 | -| `chest` | Сундук | 5–10 | -| `killer-spawn` | Спавн киллера | 1 | -| `survivor-spawn` | Спавн выжившего | 4 | - -``` -→ Режим установки: hook -→ Сломай блок чтобы установить (блок восстановится) -(ломаешь блок) -→ Крюк установлен: [game, 150, 65, 200] -``` - ---- - -### /dbdsetup set gate A|B - -Установить ворота через две точки топориком. - -``` -/dbdsetup wand — получить топорик -ЛКМ по блоку → точка 1 — нижний левый угол -ПКМ по блоку → точка 2 — верхний правый угол -/dbdsetup set gate A — установить ворота A в центре между точками -/dbdsetup set gate B — установить ворота B -``` - -Центр ворот вычисляется: X = средняя точка, Y = минимальная из двух. - -``` -→ [Wand] Точка 1: [game, 100, 64, 200] -→ [Wand] Точка 2: [game, 110, 64, 210] -→ Ворота A установлены: [game, 105, 64, 205] -``` - ---- - -### /dbdsetup build - -Построить карту по шаблону и `locations.yml`. - -``` -/dbdsetup build -``` - -``` -→ Строительство карты... -→ Карта построена! -``` - ---- - -### /dbdsetup clear - -Очистить карту. - -``` -/dbdsetup clear -``` - -``` -→ Очистка карты... -→ Карта очищена! -``` - ---- - -### /dbdsetup lobby - -Установить спавн лобби на текущую позицию. - -``` -/dbdsetup lobby -``` - -Аналог `/dbd setlobby`. Сохраняется в `locations.yml`. - ---- - -## Права доступа - -| Право | Кому | Что даёт | -|-------|------|----------| -| `mdbd.player` | Все (`default: true`) | `/dbd join`, `leave`, `lobby` | -| `mdbd.admin` | OP (`default: op`) | Все остальные команды | - ---- - -## Порядок настройки сервера - -### Первый запуск - -1. Запустить сервер — плагин создаст миры `lobby` и `game` автоматически -2. `/dbdadmin world lobby` — войти в лобби-мир и осмотреться (платформа 7x7) -3. `/dbd setlobby` — встать на нужную точку и сохранить спавн лобби - -### Настройка игровой карты - -4. `/dbdadmin world game` — перейти в игровой мир -5. Построить или загрузить карту -6. Расставить объекты: - - `/dbdsetup set generator` → ломать блоки для генераторов - - `/dbdsetup set hook` → ломать блоки для крюков - - `/dbdsetup set pallet` → ломать блоки для паллетов - - `/dbdsetup set chest` → ломать блоки для сундуков -7. Установить спавны: - - `/dbdsetup set killer-spawn` → спавн киллера - - `/dbdsetup set survivor-spawn` → 4 раза для выживших -8. Установить ворота: - - `/dbdsetup wand` → получить топорик - - ЛКМ/ПКМ по блокам → отметить углы ворот - - `/dbdsetup set gate A` и `/dbdsetup set gate B` - -### Тест - -8. `/dbd create` — создать сессию -9. `/dbd join` (5 игроков или меньше с форс-стартом) -10. `/dbd forcestart` — запустить не дожидаясь таймера - ---- - -## Таблица всех команд - -| Команда | Право | Описание | -|---------|-------|----------| -| `/dbd join` | player | Войти в очередь | -| `/dbd leave` | player | Покинуть сессию | -| `/dbd lobby` | player | Телепорт в лобби | -| `/dbd create` | admin | Создать сессию | -| `/dbd forcestart` | admin | Принудительный старт | -| `/dbd stop` | admin | Остановить игру | -| `/dbd reload` | admin | Перезагрузить конфиги | -| `/dbd setlobby` | admin | Сохранить спавн лобби | -| `/dbdadmin create generator` | admin | Режим расстановки генераторов | -| `/dbdadmin list` | admin | Список генераторов | -| `/dbdadmin remove ` | admin | Удалить генератор | -| `/dbdadmin clear` | admin | Очистить все генераторы | -| `/dbdadmin world ` | admin | Телепорт в мир | -| `/dbdsetup wand` | admin | Топорик для ворот (ЛКМ/ПКМ) | -| `/dbdsetup set <тип>` | admin | Установить объект (сломать блок) | -| `/dbdsetup set gate A\|B` | admin | Установить ворота (из точек ванда) | -| `/dbdsetup build` | admin | Построить карту | -| `/dbdsetup clear` | admin | Очистить карту | -| `/dbdsetup lobby` | admin | Сохранить спавн лобби | - ---- - -*Dead by Daylight Minecraft Plugin — актуально для версии 1.1+* +# Dead by Daylight Plugin — Руководство команд + +**Версия:** 1.2 +**Обновлено:** Июнь 2026 +**Платформа:** Minecraft 1.21.5 (Paper) + +--- + +## Содержание + +1. [Фазы игры](#фазы-игры) +2. [Команды игроков — /dbd](#команды-игроков--dbd) +3. [Команды администратора — /dbdadmin](#команды-администратора--dbdadmin) +4. [Команды настройки карты — /dbdsetup](#команды-настройки-карты--dbdsetup) +5. [Права доступа](#права-доступа) +6. [Порядок настройки сервера](#порядок-настройки-сервера) +7. [Таблица всех команд](#таблица-всех-команд) + +--- + +## Фазы игры + +``` +LOBBY + Игроки делают /dbd join → телепорт в мир lobby + Action bar показывает: ▶ Старт через: 45с ████████░░ (3/5) + Когда набралось минимум игроков — идёт отсчёт 60 сек + При нехватке игроков — таймер сбрасывается + ↓ +ROLE ASSIGN (5 сек) + Случайно выбирается 1 киллер, остальные — выжившие + Показывается тайтл с ролью + Все телепортируются на свои спавны в мире game + ↓ +GAMEPLAY + Выжившие чинят генераторы (нужно 5 из 7) + Киллер охотится + При починке всех нужных генераторов — открываются ворота + ↓ +ENDGAME (2 минуты) + Выжившие открывают ворота и убегают + Или используют люк (если остался 1 выживший) + Коллапс через 120 сек убивает всех оставшихся +``` + +--- + +## Команды игроков — /dbd + +Все команды ниже доступны любому игроку (`mdbd.player`, по умолчанию `true`). + +--- + +### /dbd join + +Присоединиться к игровой очереди. + +``` +/dbd join +``` + +- Добавляет игрока в текущую сессию как выжившего (роль назначится позже) +- Телепортирует в мир `lobby` +- В action bar начинает отображаться счётчик и таймер +- Если игра не создана — сообщение об ошибке + +``` +→ [DBD] Вы присоединились к игре! +→ [DBD] PlayerName присоединился к очереди! (1/5) +``` + +--- + +### /dbd leave + +Покинуть сессию. + +``` +/dbd leave +``` + +- В лобби: просто убирает из очереди, таймер может сброситься +- В игре: выживший считается мёртвым, киллер завершает игру +- Игрок телепортируется обратно в `lobby` + +``` +→ [DBD] Вы покинули игру +``` + +--- + +### /dbd lobby + +Телепортироваться в лобби-мир. + +``` +/dbd lobby +``` + +- Работает в любой момент, не убирает из сессии +- Телепортирует на точку спавна лобби (из `locations.yml`) или на спавн мира `lobby` + +``` +→ [DBD] Вы телепортированы в лобби! +``` + +--- + +## Команды администратора — /dbd (admin) + +Требуют право `mdbd.admin` (по умолчанию только OP). + +--- + +### /dbd create + +Создать новую игровую сессию. + +``` +/dbd create +``` + +- Создаёт сессию и открывает лобби +- Нельзя создать вторую сессию пока первая активна + +``` +→ [DBD] Игра создана! +→ [DBD] Игра уже существует (если уже есть) +``` + +--- + +### /dbd forcestart + +Принудительно запустить игру, пропустив таймер. + +``` +/dbd forcestart +``` + +- Работает только если сессия в фазе LOBBY +- Игроков в очереди может быть меньше минимума + +``` +→ [DBD] Игра форсирована! +``` + +--- + +### /dbd stop + +Немедленно завершить текущую сессию. + +``` +/dbd stop +``` + +- Все игроки телепортируются в `lobby` +- Сессия уничтожается, можно создавать новую + +``` +→ [DBD] Игра остановлена +``` + +--- + +### /dbd reload + +Перезагрузить все конфиги без перезапуска сервера. + +``` +/dbd reload +``` + +Перезагружает: `config.yml`, `game.yml`, `messages.yml`, `skillcheck.yml`, `locations.yml`, `generators.yml` + +``` +→ [DBD] Конфиги перезагружены! +``` + +> Если игра в процессе — сначала `/dbd stop`, иначе новые настройки применятся только к следующей сессии. + +--- + +### /dbd setlobby + +Установить текущую позицию игрока как точку спавна лобби. + +``` +/dbd setlobby +``` + +- Сохраняется в `locations.yml` +- Эквивалентно `/dbdsetup lobby` + +``` +→ [DBD] Лобби установлено! +``` + +--- + +## Команды администратора — /dbdadmin + +Требуют право `mdbd.admin`. Все команды только для игроков (не консоли). + +--- + +### /dbdadmin create generator + +Режим расстановки генераторов. + +``` +/dbdadmin create generator +``` + +- Включает режим: в action bar оранжевая подсказка +- **Сломай блок** — координаты сохранятся, блок не разрушится +- Повторная команда — отключает режим +- Режим остаётся активным для нескольких генераторов подряд + +``` +→ Режим выбора генератора включен! +→ Сломайте блок, чтобы установить генератор +(сломал блок) +→ Генератор установлен! Всего: 1 +``` + +**Tab complete:** `generator` + +--- + +### /dbdadmin list + +Список всех установленных генераторов. + +``` +/dbdadmin list +``` + +``` +=== Генераторы (3) === +[0] game:100.00,64.00,200.00 +[1] game:150.00,64.00,150.00 +[2] game:200.00,64.00,100.00 +``` + +--- + +### /dbdadmin remove \ + +Удалить генератор по ID из списка. + +``` +/dbdadmin remove 0 +``` + +**Tab complete:** подсказывает существующие ID (0, 1, 2...) + +``` +→ Генератор удален: game:100.00,64.00,200.00 +→ Генератор с ID 5 не найден (если нет такого) +``` + +--- + +### /dbdadmin clear + +Удалить все генераторы. + +``` +/dbdadmin clear +``` + +> Необратимо. + +``` +→ Все генераторы удалены +``` + +--- + +### /dbdadmin world \<мир\> + +Телепортироваться в указанный мир для настройки карты. + +``` +/dbdadmin world lobby +/dbdadmin world game +/dbdadmin world <любое_название_мира> +``` + +- `lobby` — телепорт в мир-лобби (void, платформа 7x7) +- `game` — телепорт в игровой мир +- Любое другое название — телепорт в мир с таким именем (если загружен) + +``` +→ Телепортирован в мир: lobby +→ Мир не найден: abc (если мир не существует) +``` + +**Tab complete:** `lobby`, `game` + +--- + +## Команды настройки карты — /dbdsetup + +Требуют право `mdbd.admin`. + +--- + +### /dbdsetup wand + +Выдать топорик для расстановки ворот. + +``` +/dbdsetup wand +``` + +- Выдаёт деревянный топорик (WOODEN_AXE) с подписью DBD Setup Wand +- **ЛКМ** по блоку = точка 1 (pos1) +- **ПКМ** по блоку = точка 2 (pos2) +- Только для ворот. Для остальных объектов ванд не нужен. + +--- + +### /dbdsetup set \<тип\> + +Войти в режим установки объекта. **Сломай блок** — объект установится на его месте, блок восстановится. + +``` +/dbdsetup set generator +/dbdsetup set hook +/dbdsetup set pallet +/dbdsetup set chest +/dbdsetup set killer-spawn +/dbdsetup set survivor-spawn +``` + +| Тип | Описание | Рекомендуемое кол-во | +|-----|----------|---------------------| +| `generator` | Генератор | 7 | +| `hook` | Крюк | 7–10 | +| `pallet` | Паллет | 15–20 | +| `chest` | Сундук | 5–10 | +| `killer-spawn` | Спавн киллера | 1 | +| `survivor-spawn` | Спавн выжившего | 4 | + +``` +→ Режим установки: hook +→ Сломай блок чтобы установить (блок восстановится) +(ломаешь блок) +→ Крюк установлен: [game, 150, 65, 200] +``` + +--- + +### /dbdsetup set gate A|B + +Установить ворота через две точки топориком. + +``` +/dbdsetup wand — получить топорик +ЛКМ по блоку → точка 1 — нижний левый угол +ПКМ по блоку → точка 2 — верхний правый угол +/dbdsetup set gate A — установить ворота A в центре между точками +/dbdsetup set gate B — установить ворота B +``` + +Центр ворот вычисляется: X = средняя точка, Y = минимальная из двух. + +``` +→ [Wand] Точка 1: [game, 100, 64, 200] +→ [Wand] Точка 2: [game, 110, 64, 210] +→ Ворота A установлены: [game, 105, 64, 205] +``` + +--- + +### /dbdsetup build + +Построить карту по шаблону и `locations.yml`. + +``` +/dbdsetup build +``` + +``` +→ Строительство карты... +→ Карта построена! +``` + +--- + +### /dbdsetup clear + +Очистить карту. + +``` +/dbdsetup clear +``` + +``` +→ Очистка карты... +→ Карта очищена! +``` + +--- + +### /dbdsetup lobby + +Установить спавн лобби на текущую позицию. + +``` +/dbdsetup lobby +``` + +Аналог `/dbd setlobby`. Сохраняется в `locations.yml`. + +--- + +## Права доступа + +| Право | Кому | Что даёт | +|-------|------|----------| +| `mdbd.player` | Все (`default: true`) | `/dbd join`, `leave`, `lobby` | +| `mdbd.admin` | OP (`default: op`) | Все остальные команды | + +--- + +## Порядок настройки сервера + +### Первый запуск + +1. Запустить сервер — плагин создаст миры `lobby` и `game` автоматически +2. `/dbdadmin world lobby` — войти в лобби-мир и осмотреться (платформа 7x7) +3. `/dbd setlobby` — встать на нужную точку и сохранить спавн лобби + +### Настройка игровой карты + +4. `/dbdadmin world game` — перейти в игровой мир +5. Построить или загрузить карту +6. Расставить объекты: + - `/dbdsetup set generator` → ломать блоки для генераторов + - `/dbdsetup set hook` → ломать блоки для крюков + - `/dbdsetup set pallet` → ломать блоки для паллетов + - `/dbdsetup set chest` → ломать блоки для сундуков +7. Установить спавны: + - `/dbdsetup set killer-spawn` → спавн киллера + - `/dbdsetup set survivor-spawn` → 4 раза для выживших +8. Установить ворота: + - `/dbdsetup wand` → получить топорик + - ЛКМ/ПКМ по блокам → отметить углы ворот + - `/dbdsetup set gate A` и `/dbdsetup set gate B` + +### Тест + +8. `/dbd create` — создать сессию +9. `/dbd join` (5 игроков или меньше с форс-стартом) +10. `/dbd forcestart` — запустить не дожидаясь таймера + +--- + +## Таблица всех команд + +| Команда | Право | Описание | +|---------|-------|----------| +| `/dbd join` | player | Войти в очередь | +| `/dbd leave` | player | Покинуть сессию | +| `/dbd lobby` | player | Телепорт в лобби | +| `/dbd create` | admin | Создать сессию | +| `/dbd forcestart` | admin | Принудительный старт | +| `/dbd stop` | admin | Остановить игру | +| `/dbd reload` | admin | Перезагрузить конфиги | +| `/dbd setlobby` | admin | Сохранить спавн лобби | +| `/dbdadmin create generator` | admin | Режим расстановки генераторов | +| `/dbdadmin list` | admin | Список генераторов | +| `/dbdadmin remove ` | admin | Удалить генератор | +| `/dbdadmin clear` | admin | Очистить все генераторы | +| `/dbdadmin world ` | admin | Телепорт в мир | +| `/dbdsetup wand` | admin | Топорик для ворот (ЛКМ/ПКМ) | +| `/dbdsetup set <тип>` | admin | Установить объект (сломать блок) | +| `/dbdsetup set gate A\|B` | admin | Установить ворота (из точек ванда) | +| `/dbdsetup build` | admin | Построить карту | +| `/dbdsetup clear` | admin | Очистить карту | +| `/dbdsetup lobby` | admin | Сохранить спавн лобби | + +--- + +*Dead by Daylight Minecraft Plugin — актуально для версии 1.1+* diff --git a/build.gradle.kts b/build.gradle.kts index 152fd35..8f2e0b7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,16 @@ dependencies { compileOnly("io.papermc.paper:paper-api:1.21.5-R0.1-SNAPSHOT") compileOnly("com.comphenix.protocol:ProtocolLib:5.3.0") proguardConfig("com.guardsquare:proguard-base:7.4.2") + + testImplementation("io.papermc.paper:paper-api:1.21.5-R0.1-SNAPSHOT") + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") + testImplementation("org.mockito:mockito-core:5.14.2") + testImplementation("org.mockito:mockito-junit-jupiter:5.14.2") +} + +tasks.test { + useJUnitPlatform() } java { @@ -72,8 +82,9 @@ val obfuscateTask = tasks.register("obfuscate") { "-keep", "class dev.loki.config.** { *; }", "-dontoptimize", + "-dontobfuscate", + "-dontpreverify", "-allowaccessmodification", - "-repackageclasses", "mdbd", "-keepattributes", "Exceptions,InnerClasses,Signature,Deprecated,SourceFile,LineNumberTable,EnclosingMethod" ) @@ -117,7 +128,5 @@ tasks { } } - named("build") { - dependsOn("replaceWithObfuscated") - } + // obfuscation is optional — run `gradle obfuscate` manually } diff --git a/src/test/java/dev/loki/gate/ExitGateTest.java b/src/test/java/dev/loki/gate/ExitGateTest.java new file mode 100644 index 0000000..55e4242 --- /dev/null +++ b/src/test/java/dev/loki/gate/ExitGateTest.java @@ -0,0 +1,145 @@ +package dev.loki.gate; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class ExitGateTest { + + private ExitGate gate; + + @BeforeEach + void setUp() { + gate = new ExitGate(new org.bukkit.Location(null, 100, 64, 200)); + } + + @Test + void initialState_shouldBeClosedAndNotPowered() { + assertEquals(ExitGate.GateState.CLOSED, gate.getState()); + assertEquals(0.0, gate.getProgress()); + assertEquals(0, gate.getProgressPercent()); + assertFalse(gate.isPowered()); + assertTrue(gate.isClosed()); + assertFalse(gate.isOpening()); + assertFalse(gate.isOpen()); + } + + @Test + void setPowered_shouldTransitionToOpening() { + gate.setPowered(true); + assertEquals(ExitGate.GateState.OPENING, gate.getState()); + assertTrue(gate.isPowered()); + assertTrue(gate.isOpening()); + assertFalse(gate.isClosed()); + assertFalse(gate.isOpen()); + } + + @Test + void setProgressDirect_shouldIncreaseProgress() { + gate.setPowered(true); + gate.setProgressDirect(0.5); + assertEquals(0.5, gate.getProgress()); + assertEquals(50, gate.getProgressPercent()); + assertTrue(gate.isOpening()); + } + + @Test + void setProgressDirect_shouldCompleteWhenReachingOne() { + gate.setPowered(true); + gate.setProgressDirect(1.0); + assertEquals(1.0, gate.getProgress()); + assertEquals(ExitGate.GateState.OPEN, gate.getState()); + assertTrue(gate.isOpen()); + } + + @Test + void setProgressDirect_shouldClampToMax() { + gate.setPowered(true); + gate.setProgressDirect(2.0); + assertEquals(1.0, gate.getProgress()); + assertTrue(gate.isOpen()); + } + + @Test + void setProgressDirect_shouldNotExceedMin() { + gate.setPowered(true); + gate.setProgressDirect(-0.5); + assertEquals(0.0, gate.getProgress()); + } + + @Test + void setProgressDirect_shouldNotWork_whenGateIsClosed() { + // Not powered — still CLOSED + gate.setProgressDirect(0.5); + assertEquals(0.0, gate.getProgress()); + assertTrue(gate.isClosed()); + } + + @Test + void updateProgress_shouldWorkIncrementally() { + gate.setPowered(true); + gate.updateProgress(0.3); + assertEquals(0.3, gate.getProgress()); + gate.updateProgress(0.3); + assertEquals(0.6, gate.getProgress()); + } + + @Test + void reset_shouldRestoreInitialState() { + gate.setPowered(true); + gate.setProgressDirect(0.8); + gate.reset(); + + assertEquals(ExitGate.GateState.CLOSED, gate.getState()); + assertEquals(0.0, gate.getProgress()); + assertEquals(0, gate.getProgressPercent()); + assertFalse(gate.isPowered()); + assertTrue(gate.isClosed()); + } + + @Test + void saveAndRestoreProgress_shouldPersist() { + gate.setPowered(true); + gate.setProgressDirect(0.4); + assertEquals(0.4, gate.getProgress()); + + // Simulate player walks away then returns in same session + gate.saveProgress(); + gate.setProgressDirect(0.0); // simulate some other state change + gate.restoreProgress(); + assertEquals(0.4, gate.getProgress()); + } + + @Test + void isInRange_shouldCheckDistance() { + org.bukkit.World w = mock(org.bukkit.World.class); + var gateLoc = new org.bukkit.Location(w, 100, 64, 200); + var close = new org.bukkit.Location(w, 101, 64, 200); + var far = new org.bukkit.Location(w, 110, 64, 200); + ExitGate g = new ExitGate(gateLoc); + + assertTrue(g.isInRange(close, 3.0)); + assertFalse(g.isInRange(far, 3.0)); + } + + @Test + void startOpening_shouldWork_whenClosedAndPowered() { + gate.setPowered(true); + gate.startOpening(); + assertTrue(gate.isOpening()); + } + + @Test + void startOpening_shouldNotWork_whenNotPowered() { + gate.startOpening(); + assertTrue(gate.isClosed()); + } + + @Test + void spawnAndRemoveGateBlock_shouldNotThrowWithNullWorld() { + // Location has null world so block ops are no-ops — just verify no exception + assertDoesNotThrow(() -> gate.spawnGateBlock()); + assertDoesNotThrow(() -> gate.removeGateBlock()); + } +} diff --git a/src/test/java/dev/loki/phase/GameplayWinConditionsTest.java b/src/test/java/dev/loki/phase/GameplayWinConditionsTest.java new file mode 100644 index 0000000..2b0cbac --- /dev/null +++ b/src/test/java/dev/loki/phase/GameplayWinConditionsTest.java @@ -0,0 +1,241 @@ +package dev.loki.phase; + +import dev.loki.config.ConfigManager; +import dev.loki.config.MessagesConfig; +import dev.loki.core.GameSession; +import dev.loki.mdbd; +import dev.loki.player.BasePlayer; +import dev.loki.player.Killer; +import dev.loki.player.PlayerState; +import dev.loki.player.Survivor; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Tests win condition logic by spying on GameplayWinConditions and + * stubbing Bukkit-bound announce methods. This avoids loading + * Paper API classes (Material, Sound, GameMode) whose static + * initializers require a running server. + */ +@ExtendWith(MockitoExtension.class) +class GameplayWinConditionsTest { + + private GameplayWinConditions conditions; + + // Reusable stubs (reflection-based) for allSurvivorsIncapacitated + private java.lang.reflect.Method incapacitatedMethod; + + @BeforeEach + void setUp() throws Exception { + conditions = new GameplayWinConditions(); + incapacitatedMethod = GameplayWinConditions.class + .getDeclaredMethod("allSurvivorsIncapacitated", GameSession.class); + incapacitatedMethod.setAccessible(true); + } + + // ========================================================================= + // Helper: build session with player map + // ========================================================================= + + private GameSession sessionWith(Map players) { + GameSession session = mock(GameSession.class); + lenient().when(session.getPlayers()).thenReturn(players); + + // Mock messages chain for GameplayWinConditions + mdbd plugin = mock(mdbd.class); + ConfigManager configManager = mock(ConfigManager.class); + MessagesConfig messages = mock(MessagesConfig.class); + lenient().when(session.getPlugin()).thenReturn(plugin); + lenient().when(plugin.getConfigManager()).thenReturn(configManager); + lenient().when(configManager.getMessages()).thenReturn(messages); + lenient().when(messages.get(anyString())).thenReturn(Component.text("test")); + lenient().when(messages.get(anyString(), any())).thenReturn(Component.text("test")); + + return session; + } + + private Killer onlineKiller() { + Killer k = mock(Killer.class); + lenient().when(k.isKiller()).thenReturn(true); + Player p = mock(Player.class); + lenient().when(p.isOnline()).thenReturn(true); + lenient().when(k.getPlayer()).thenReturn(p); + return k; + } + + private Survivor survivorWithState(PlayerState state) { + Survivor s = mock(Survivor.class); + lenient().when(s.isKiller()).thenReturn(false); + lenient().when(s.getState()).thenReturn(state); + return s; + } + + /** Invoke private allSurvivorsIncapacitated via reflection. */ + private boolean allIncapacitated(GameSession session) { + try { + return (boolean) incapacitatedMethod.invoke(conditions, session); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + // ========================================================================= + // Bug #3 — ESCAPED survivor blocks killer win + // ========================================================================= + + @Test + void incapacitated_withEscapedAndRestDisabled_shouldReturnTrue() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.ESCAPED)); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.DOWNED)); + + assertTrue(allIncapacitated(sessionWith(players)), + "ESCAPED survivors should be counted as incapacitated (bug #3)"); + } + + @Test + void incapacitated_withEscapedAndHealthy_shouldReturnFalse() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.ESCAPED)); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.HEALTHY)); + + assertFalse(allIncapacitated(sessionWith(players)), + "Healthy survivor means not all incapacitated"); + } + + @Test + void incapacitated_withAllTerminalStates_shouldReturnTrue() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.DEAD)); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.ESCAPED)); + + assertTrue(allIncapacitated(sessionWith(players))); + } + + // ========================================================================= + // Bug #4 — Zero survivors → killer win + // ========================================================================= + + @Test + void zeroSurvivors_shouldNotHaveIncapacitatedTrue() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + + assertFalse(allIncapacitated(sessionWith(players)), + "With no survivors, allSurvivorsIncapacitated returns false (bug #4)"); + } + + // ========================================================================= + // Normal incapacitation states + // ========================================================================= + + @Test + void allDowned_shouldBeIncapacitated() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.DOWNED)); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.DOWNED)); + assertTrue(allIncapacitated(sessionWith(players))); + } + + @Test + void allHoodedCarriedDead_shouldBeIncapacitated() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.HOOKED)); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.CARRIED)); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.DEAD)); + assertTrue(allIncapacitated(sessionWith(players))); + } + + @Test + void healthySurvivor_shouldNotBeIncapacitated() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.HEALTHY)); + assertFalse(allIncapacitated(sessionWith(players))); + } + + @Test + void injuredSurvivor_shouldNotBeIncapacitated() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.INJURED)); + assertFalse(allIncapacitated(sessionWith(players))); + } + + // ========================================================================= + // checkWinConditions — spy-based tests for bug #4 and bug #9 + // These use a spy so announce methods (which load Sound/GameMode) are + // stubbed out to avoid Paper API class loading issues outside a server. + // ========================================================================= + + /** Helper: run checkWinConditions on a spy with Bukkit broadcast suppressed. */ + private void checkOnSpy(GameplayWinConditions spy, GameSession session) { + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.broadcast(any(Component.class))).then(inv -> null); + spy.checkWinConditions(session); + } + } + + @Test + void zeroSurvivors_checkWinConditions_shouldEndAsKillerWin() { + Map players = new HashMap<>(); + players.put(UUID.randomUUID(), onlineKiller()); + GameSession session = sessionWith(players); + + GameplayWinConditions spy = spy(new GameplayWinConditions()); + doNothing().when(spy).announceKillerWin(any()); + + checkOnSpy(spy, session); + + assertTrue(spy.isShouldEnd(), "checkWinConditions should end with zero survivors (bug #4)"); + assertEquals("killer_win", spy.getWinReason()); + } + + @Test + void killerDisconnectGuard_shouldPreventDoubleProcess() throws Exception { + Map players = new LinkedHashMap<>(); + // Offline killer must be first so getKiller() finds it deterministically + Killer offlineK = mock(Killer.class); + when(offlineK.isKiller()).thenReturn(true); + when(offlineK.getPlayer()).thenReturn(null); + players.put(UUID.randomUUID(), offlineK); + players.put(UUID.randomUUID(), survivorWithState(PlayerState.HEALTHY)); + GameSession session = sessionWith(players); + + GameplayWinConditions spy = spy(new GameplayWinConditions()); + doNothing().when(spy).announceSurvivorsWin(any()); + + // Tick 1 — should process + checkOnSpy(spy, session); + assertTrue(spy.isShouldEnd()); + assertEquals("survivors_win", spy.getWinReason()); + + // Tick 2 — should NOT re-process; guard catches shouldEnd=true + // Use a new static mock that fails if broadcast is called + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.broadcast(any(Component.class))) + .thenThrow(new AssertionError("Second broadcast despite guard (bug #9)")); + spy.checkWinConditions(session); + } + // If we get here, the guard worked correctly + } +} diff --git a/src/test/java/dev/loki/player/PlayerStateTest.java b/src/test/java/dev/loki/player/PlayerStateTest.java new file mode 100644 index 0000000..855c57d --- /dev/null +++ b/src/test/java/dev/loki/player/PlayerStateTest.java @@ -0,0 +1,44 @@ +package dev.loki.player; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class PlayerStateTest { + + @Test + void values_shouldContainAllExpectedStates() { + PlayerState[] states = PlayerState.values(); + assertArrayEquals(new PlayerState[]{ + PlayerState.HEALTHY, + PlayerState.INJURED, + PlayerState.DOWNED, + PlayerState.HOOKED, + PlayerState.CARRIED, + PlayerState.DEAD, + PlayerState.ESCAPED + }, states); + } + + @Test + void deadAndEscaped_shouldBeTerminal() { + assertTrue(PlayerState.DEAD.isDeadOrEscaped()); + assertTrue(PlayerState.ESCAPED.isDeadOrEscaped()); + } + + @Test + void aliveStates_shouldNotBeTerminal() { + assertFalse(PlayerState.HEALTHY.isDeadOrEscaped()); + assertFalse(PlayerState.INJURED.isDeadOrEscaped()); + assertFalse(PlayerState.DOWNED.isDeadOrEscaped()); + assertFalse(PlayerState.HOOKED.isDeadOrEscaped()); + assertFalse(PlayerState.CARRIED.isDeadOrEscaped()); + } + + @Test + void displayNames_shouldNotBeEmpty() { + for (PlayerState state : PlayerState.values()) { + assertNotNull(state.getDisplayName()); + assertFalse(state.getDisplayName().isEmpty()); + } + } +} diff --git a/src/test/java/dev/loki/util/SpawnUtilTest.java b/src/test/java/dev/loki/util/SpawnUtilTest.java new file mode 100644 index 0000000..163074b --- /dev/null +++ b/src/test/java/dev/loki/util/SpawnUtilTest.java @@ -0,0 +1,93 @@ +package dev.loki.util; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SpawnUtilTest { + + @Mock World world; + + @Test + void resolveStandingLocation_shouldReturnNull_whenInputIsNull() { + assertNull(SpawnUtil.resolveStandingLocation(null)); + } + + @Test + void resolveStandingLocation_shouldReturnRaw_whenWorldIsNull() { + Location loc = new Location(null, 0, 64, 0); + Location result = SpawnUtil.resolveStandingLocation(loc); + assertSame(loc, result, "Should return raw location when world is null"); + } + + @Test + void resolveStandingLocation_shouldFallbackToHighestBlock_whenConfiguredYIsInAir() { + Block anyBlock = stubBlockAsAir(); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(anyBlock); + when(world.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(64); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(world.getMinHeight()).thenReturn(-64); + + Location input = new Location(world, 0.5, 100, 0.5); + Location result = SpawnUtil.resolveStandingLocation(input); + + assertNotNull(result); + assertEquals(65.0, result.getY(), "Highest block (64) + 1 = 65"); + assertEquals(0.5, result.getX()); + assertEquals(0.5, result.getZ()); + } + + @Test + void resolveStandingLocation_shouldUseConfiguredY_whenChunkNotLoaded() { + Block anyBlock = stubBlockAsAir(); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(anyBlock); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + when(world.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(0); + when(world.getMinHeight()).thenReturn(0); + + Location input = new Location(world, 0.5, 50, 0.5); + Location result = SpawnUtil.resolveStandingLocation(input); + + assertNotNull(result); + assertEquals(65.0, result.getY(), "Fallback should use 64 when configured Y is low"); + } + + /** Create a Block mock whose getType() returns a Mockito mock of Material. */ + private static Block stubBlockAsAir() { + Block b = mock(Block.class); + Material mat = mock(Material.class); + lenient().when(mat.isAir()).thenReturn(true); + lenient().when(mat.isSolid()).thenReturn(false); + lenient().when(b.getType()).thenReturn(mat); + return b; + } + + @Test + void blockBreakSpawn_shouldCenterOnBlockAndAddOne() { + Location blockLoc = new Location(world, 10, 64, 20); + Location result = SpawnUtil.blockBreakSpawn(blockLoc, 90f); + + assertEquals(10.5, result.getX(), "Should be centered X"); + assertEquals(65.0, result.getY(), "Should be block Y + 1"); + assertEquals(20.5, result.getZ(), "Should be centered Z"); + assertEquals(90f, result.getYaw()); + assertEquals(0f, result.getPitch()); + } + + @Test + void blockBreakSpawn_shouldPreserveWorld() { + Location blockLoc = new Location(world, 0, 0, 0); + Location result = SpawnUtil.blockBreakSpawn(blockLoc, 0f); + assertSame(world, result.getWorld()); + } +}