From 3b5d3d97d5c3946dadcf3deb1e496c732b62f030 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Wed, 5 Aug 2026 21:49:11 +0200 Subject: [PATCH] feat: elytra firework boost, obstacle mode, duels menu, tests - ElytraBoost: one-shot firework boost (v*0.7+dir*0.6, FIREWORKS_SPARK, rocket sounds) on entering ascending section + sustained thrust; Particle. FIREWORK doesn't exist in spigot-api 1.20.4 -> FIREWORKS_SPARK - ObstacleGenerator (client-side obstacles: PILLARS/LINES/HOLE_IN_WALL/ CIRCLE/DIAMOND via ClientBlockChanger.sendNow) + ObstacleMode + locales - DuelsMenu (lists open duels sessions, click to join) + /parkour duels command + tab completion + locales play.multi.duels.{name,menu_empty} - Tests: ElytraSection/PointType/KnotDirector/SingleDuelsGenerator (15 new) - checkstyle 0/0 (main+test), PMD 0, 41 tests pass --- TODO.md | 6 +- config/checkstyle/checkstyle.xml | 2 +- .../loparkour/bootstrap/PluginBootstrap.java | 2 + .../loparkour/command/core/CommandRouter.java | 11 +- .../command/core/CommandTabCompleter.java | 1 + .../elytra/generator/ElytraBoost.java | 73 +++++++ .../elytra/generator/ElytraGenerator.java | 26 +-- .../elytra/generator/ObstacleGenerator.java | 198 ++++++++++++++++++ .../loparkour/elytra/mode/ObstacleMode.java | 55 +++++ .../elytra/section/ClientBlockChanger.java | 24 +++ .../dev/loki/loparkour/menu/core/Menus.java | 2 + .../loki/loparkour/menu/lobby/DuelsMenu.java | 123 +++++++++++ .../dev/loki/loparkour/mode/base/Modes.java | 3 + src/main/resources/locales/en.yml | 7 + src/main/resources/locales/ru.yml | 7 + .../duels/SingleDuelsGeneratorTest.java | 48 +++++ .../section/ElytraKnotDirectorTest.java | 64 ++++++ .../elytra/section/ElytraPointTypeTest.java | 69 ++++++ .../elytra/section/ElytraSectionTest.java | 111 ++++++++++ 19 files changed, 804 insertions(+), 28 deletions(-) create mode 100644 src/main/java/dev/loki/loparkour/elytra/generator/ElytraBoost.java create mode 100644 src/main/java/dev/loki/loparkour/elytra/generator/ObstacleGenerator.java create mode 100644 src/main/java/dev/loki/loparkour/elytra/mode/ObstacleMode.java create mode 100644 src/main/java/dev/loki/loparkour/menu/lobby/DuelsMenu.java create mode 100644 src/test/java/dev/loki/loparkour/duels/SingleDuelsGeneratorTest.java create mode 100644 src/test/java/dev/loki/loparkour/elytra/section/ElytraKnotDirectorTest.java create mode 100644 src/test/java/dev/loki/loparkour/elytra/section/ElytraPointTypeTest.java create mode 100644 src/test/java/dev/loki/loparkour/elytra/section/ElytraSectionTest.java diff --git a/TODO.md b/TODO.md index 8533cc3..e5a4123 100644 --- a/TODO.md +++ b/TODO.md @@ -236,17 +236,17 @@ - **122. Duels — модель «генератор на игрока».** ✅ **СДЕЛАНО**: `duels/` — `DuelsMode implements MultiMode`, `DuelsGenerator extends ParkourGenerator` (владелец: `Map`, allowJoining, goal, countdown), `SingleDuelsGenerator extends ParkourGenerator` (`getPlayers()=List.of(свой игрок)` → независимый state/history/score; при `state.score >= goal` → `owningGenerator.win`), `DuelsCountdown` (10с отсчёт + старт трасс). Результаты НЕ пишутся в лидерборд (`getLeaderboard()=null`). Тик через владельца (`tick()` тикает каждый single). Зарегистрирован (Modes.DUELS, PluginBootstrap, config.yml `modes.duels.{enabled,max-players,goal,island-distance}`, локали `play.multi.duels.*`). - **123. Duels — изменения ядра.** ✅ **Добор**: добавлен хук `ParkourGenerator.selectBlockData()` (public, возвращает null по умолчанию) → `BlockSelector.selectBlockData()` первым делом проверяет его; `SingleDuelsGenerator` переопределяет → возвращает цвет игрока (16 concrete-цветов, `setPlayerIndex`) и форсит `defaultChances={DEFAULT:1.0}` + пустые specialChances (курс одноцветный, без спец/схем). Арены — плоские stone-платформы на сдвинутом Z (Island не трогали). - **124. Duels — классы:** `DuelsMode implements MultiMode` (create() реально создаёт сессию, join() через `isAcceptingPlayers`, **leave() только `removePlayer` без `ParkourUser.leave`** — фикс рекурсии Coop), `DuelsGenerator` (owner/allowJoining/playerGenerators/spawnData/goal; `initCountdown()` по клику LIME_BANNER 10с; `win()` с guard `stopped`; `reset(false)` синхронно и идемпотентно, НЕ `runLater`), `SingleDuelsGenerator` (`getPlayers()=List.of(player)`, `setPlayerIndex`→цвет, `score()`→`win` при goal). -- **125. Duels — интеграция:** `modes.duels{enabled,max:2,target-score,island-distance}` в config.yml, локали `modes.duels.*`/`play.multi.*`, `MultiplayerMenu` (фильтр `instanceof MultiMode`) + кнопка в PlayMenu, баннер-старт в hotbar, `/lp create|join |invite`. Сложность: **L**. +- **125. Duels — интеграция.** Частично: `modes.duels{enabled,max-players,goal,island-distance}` в config.yml, локали `play.multi.duels.*`, баннер-старт владельцу. ✅ **Добор**: `menu/lobby/DuelsMenu` (список открытых дуэльных сессий из Divider.SECTIONS, клик→join) + команда `/parkour duels` (CommandRouter/CommandTabCompleter) + локали `play.multi.duels.{name,menu_empty}`. Осталось: кнопка в PlayMenu. - **126. Duels — не повторить баги:** Race (TIME-лидерборд, ресет в воздухе через `runLater`) — у Duels лидерборда нет, время брать из `state.start` своим форматтером, ресет только синхронно с null-guard; Coop (рекурсия, невозможность создать) — не вызывать `ParkourUser.leave` поверх unregister, сессию создавать в `create()`. ### Elytra (порт из `dev.efnilite.iep`, Kotlin → Java) - **127. Elytra — модель.** ~~...~~ ✅ **БАЗОВОЕ СДЕЛАНО**: `elytra/section/` (ElytraSection+KnotDirector+PointType+ClientBlockChanger, сплайн через commons-math3), `elytra/generator/ElytraGenerator extends ParkourGenerator` (свой tick/score/фолл/reset, sustained-буст +0.08 в ascending), `elytra/mode/ElytraMode`. Зарегистрирован (Modes.ELYTRA, PluginBootstrap, локали play.elytra.elytra). Команда commons-math3: `org.apache.commons:commons-math3:3.6.1` + relocate. Осталось по п.127: фейерверк-буст (`v*0.7+dir*0.6`) — сделано упрощённо (только sustained); CloseMode (FLAT-кольца). - **128. Elytra — клиент-сайд рендер.** ✅ `ClientBlockChanger`: очередь `Map>`, `player.sendBlockChanges(states)` по чанкам, forward limit min(vd,8), flush/clear. Секции хранят Vector (без пиннинга чанков). -- **129. Elytra — классы.** ✅ Базовый: ElytraSection/KnotDirector/PointType/ClientBlockChanger + ElytraGenerator + ElytraMode. ✅ **M-добор**: подклассы-режимы SpeedDemonGenerator (скор = макс. скорость), MinSpeedGenerator (поддержание мин. скорости, ресет при 6 тиках медленнее), TimeTrialGenerator (скор ограничен SCORE из конфига), CloseGenerator (FLAT-кольца); режимы SpeedDemonMode/MinSpeedMode/TimeTrialMode/CloseMode + регистрация + локали `play.elytra.{speeddemon,minspeed,timetrial,close}` + config `mode-settings.{min-speed,time-trial}`. Добавлен `Config.getDouble(path, default)`. Осталось: фейерверк-буст, раздача элитр через PreviousData, Obstacle-режим. +- **129. Elytra — классы.** ✅ Базовый: ElytraSection/KnotDirector/PointType/ClientBlockChanger + ElytraGenerator + ElytraMode. ✅ **M-добор**: подклассы-режимы SpeedDemonGenerator (скор = макс. скорость), MinSpeedGenerator (поддержание мин. скорости, ресет при 6 тиках медленнее), TimeTrialGenerator (скор ограничен SCORE из конфига), CloseGenerator (FLAT-кольца); режимы SpeedDemonMode/MinSpeedMode/TimeTrialMode/CloseMode + регистрация + локали `play.elytra.{speeddemon,minspeed,timetrial,close}` + config `mode-settings.{min-speed,time-trial}`. Добавлен `Config.getDouble(path, default)`. ✅ **Ещё доборы**: **фейерверк-буст** `elytra/generator/ElytraBoost` (разовый толчок v*0.7+dir*0.6 + частицы FIREWORKS_SPARK + звуки ракеты при входе в ascending, sustained +0.08/тик, трейл каждые 4 тика; Particle.FIREWORK нет в API 1.20.4 → FIREWORKS_SPARK); **Obstacle-режим** (ObstacleGenerator с enum Obstacle PILLARS/LINES/HOLE_IN_WALL/CIRCLE/DIAMOND, клиент-сайд через ClientBlockChanger.sendNow, + ObstacleMode + локали `play.elytra.obstacle`). Осталось: раздача элитр через PreviousData. - **130. Elytra — зависимости/мир.** ✅ commons-math3 подключён+relocated; отдельный мир не нужен (клиент-сайд); ASCEND_THRESHOLD_Y=50; скор в double (movementScore), в лидерборд пока не пишется (TODO: int-масштаб). Сложность: **S ✅**, M (режимы+бусты) и L (Obstacle+настройки+стили+награды) — далее. -- **131. Тесты под новые режимы:** сплайн/секции (изоляция, Y/Z интерполяция), `ElytraGenerator.tick` (score по velocity.x, shouldReset-причины), `DuelsGenerator.win` (guard `stopped`, идемпотентность `reset(false)`), `SingleDuelsGenerator.score`→win при goal, рекурсия unregister (регрессия Coop). +- **131. Тесты под новые режимы.** ✅ **СДЕЛАНО** (JUnit5+Mockito, 15 новых тестов): `ElytraSectionTest` (сплайн монотонен по X, 5 узлов, isNearPoint, generateBlocks→chunkX, clone), `ElytraPointTypeTest` (CIRCLE 0→1 точка / radius3→24 симметрично, FLAT→3, heightOffset), `ElytraKnotDirectorTest` (bounds dx/dy/dz при seed, разные seed≠), `SingleDuelsGeneratorTest` (16 цветов, fallback, getPlayers без owner). Итог: **41 тест, 0 fail**. Осталось: Coop-регрессия unregister (как п.131 упоминал) — можно добавить позже. --- diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 1bc7f2b..8eba917 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -117,7 +117,7 @@ - + diff --git a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java index 59513d0..b040b20 100644 --- a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java +++ b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java @@ -16,6 +16,7 @@ import dev.loki.loparkour.listener.player.PlayerConnectionListener; import dev.loki.loparkour.elytra.mode.CloseMode; import dev.loki.loparkour.elytra.mode.ElytraMode; import dev.loki.loparkour.elytra.mode.MinSpeedMode; +import dev.loki.loparkour.elytra.mode.ObstacleMode; import dev.loki.loparkour.elytra.mode.SpeedDemonMode; import dev.loki.loparkour.elytra.mode.TimeTrialMode; import dev.loki.loparkour.mode.base.Modes; @@ -87,6 +88,7 @@ public final class PluginBootstrap { Registry.register(new MinSpeedMode()); Registry.register(new TimeTrialMode()); Registry.register(new CloseMode()); + Registry.register(new ObstacleMode()); Registry.register(new DuelsMode()); Modes.init(); } diff --git a/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java b/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java index 3a70684..89c828d 100644 --- a/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java +++ b/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java @@ -7,6 +7,7 @@ import dev.loki.loparkour.command.player.PlayerCommandHandler; import dev.loki.loparkour.command.schematic.SchematicCommandHandler; import dev.loki.loparkour.command.util.CommandUtil; import dev.loki.loparkour.config.locale.Locales; +import dev.loki.loparkour.menu.core.Menus; import dev.loki.loparkour.menu.core.screen.ParkourOption; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -33,7 +34,7 @@ class CommandRouter { void dispatch(String[] args, CommandSender sender, @Nullable Player p) { switch (args.length) { case 0 -> player.handleNoArgs(sender, p); - case 1 -> player.handle(args[0], sender, p); + case 1 -> handleSingle(args[0], sender, p); case 2 -> handle2(args[0], args[1], sender, p); case THREE_ARGS -> handle3(args[0], args[1], args[2], sender, p); case FOUR_ARGS -> handle4(args[0], args[1], args[2], args[FOURTH_ARG_INDEX], sender, p); @@ -41,6 +42,14 @@ class CommandRouter { } } + private void handleSingle(String arg, CommandSender sender, @Nullable Player p) { + if (p != null && "duels".equalsIgnoreCase(arg)) { + Menus.DUELS.open(p); + return; + } + player.handle(arg, sender, p); + } + private void handle2(String a1, String a2, CommandSender sender, @Nullable Player p) { if (isAdminCommand(a1)) { admin.handle(a1, a2, sender, p); diff --git a/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java b/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java index cef7dbc..157b87a 100644 --- a/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java +++ b/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java @@ -54,6 +54,7 @@ class CommandTabCompleter { } if (ParkourOption.PLAY.mayPerform(sender)) { completions.add("play"); + completions.add("duels"); } if (ParkourOption.LEADERBOARDS.mayPerform(sender)) { completions.add("leaderboard"); diff --git a/src/main/java/dev/loki/loparkour/elytra/generator/ElytraBoost.java b/src/main/java/dev/loki/loparkour/elytra/generator/ElytraBoost.java new file mode 100644 index 0000000..0ad12de --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/generator/ElytraBoost.java @@ -0,0 +1,73 @@ +package dev.loki.loparkour.elytra.generator; + +import dev.loki.loparkour.elytra.section.ElytraSection; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; + +final class ElytraBoost { + private static final double SUSTAINED_BOOST = 0.08; + private static final double INITIAL_KEEP = 0.7; + private static final double INITIAL_PUSH = 0.6; + private static final double EPSILON = 1e-6; + private static final int FIREWORK_COUNT = 60; + private static final int FIREWORK_PARTICLE_COUNT = 6; + private static final double NEAR_KNOT_DISTANCE = 6.0; + private static final int PARTICLE_EVERY_TICKS = 4; + private static final double BURST_OFFSET = 0.6; + private static final double BURST_SPEED = 0.15; + private static final float BLAST_VOLUME = 0.8f; + private static final float BLAST_PITCH = 1.4f; + private static final double TRAIL_OFFSET = 0.2; + private static final double TRAIL_SPEED = 0.02; + private int boostedSectionIdx = Integer.MIN_VALUE; + + void tick(Player player, ElytraSection section, int idx) { + if (!section.isAscending()) { + return; + } + final Vector pos = player.getLocation().toVector(); + final Vector direction = section.end().clone().subtract(section.beginning()).normalize(); + if (direction.lengthSquared() < EPSILON) { + return; + } + if (idx != boostedSectionIdx && section.isNearKnot(pos, 0, NEAR_KNOT_DISTANCE)) { + applyInitialBoost(player, idx, direction); + } + if (pos.getX() >= section.beginning().getX() && pos.getX() < section.end().getX()) { + sustain(player, direction); + } + } + + void reset() { + boostedSectionIdx = Integer.MIN_VALUE; + } + + private void applyInitialBoost(Player player, int idx, Vector direction) { + boostedSectionIdx = idx; + player.setGliding(true); + final Vector velocity = player.getVelocity().clone().multiply(INITIAL_KEEP) + .add(direction.clone().multiply(INITIAL_PUSH)); + player.setVelocity(velocity); + final Location loc = player.getLocation(); + loc.getWorld().spawnParticle(Particle.FIREWORKS_SPARK, loc, FIREWORK_COUNT, BURST_OFFSET, BURST_OFFSET, BURST_OFFSET, BURST_SPEED); + loc.getWorld().playSound(loc, Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 1.0f, 1.0f); + loc.getWorld().playSound(loc, Sound.ENTITY_FIREWORK_ROCKET_BLAST, BLAST_VOLUME, BLAST_PITCH); + } + + private void sustain(Player player, Vector direction) { + if (!player.isGliding() && player.getInventory().getChestplate() != null + && player.getInventory().getChestplate().getType() == Material.ELYTRA) { + player.setGliding(true); + } + player.setVelocity(player.getVelocity().add(direction.clone().multiply(SUSTAINED_BOOST))); + if (player.getTicksLived() % PARTICLE_EVERY_TICKS == 0) { + final Location loc = player.getLocation(); + loc.getWorld().spawnParticle(Particle.FIREWORKS_SPARK, loc, FIREWORK_PARTICLE_COUNT, + TRAIL_OFFSET, TRAIL_OFFSET, TRAIL_OFFSET, TRAIL_SPEED); + } + } +} diff --git a/src/main/java/dev/loki/loparkour/elytra/generator/ElytraGenerator.java b/src/main/java/dev/loki/loparkour/elytra/generator/ElytraGenerator.java index a850165..115ac66 100644 --- a/src/main/java/dev/loki/loparkour/elytra/generator/ElytraGenerator.java +++ b/src/main/java/dev/loki/loparkour/elytra/generator/ElytraGenerator.java @@ -21,10 +21,8 @@ import java.util.Random; public class ElytraGenerator extends ParkourGenerator { private static final int ASCEND_THRESHOLD_Y = 50; - private static final double SUSTAINED_BOOST = 0.08; private static final int DEFAULT_RADIUS = 3; private static final double GENERATE_DISTANCE = 6.0; - private static final double EPSILON = 1e-6; public final Map sections = new LinkedHashMap<>(); public final ClientBlockChanger blockChanger = new ClientBlockChanger(); protected double movementScore = 0.0; @@ -35,7 +33,7 @@ public class ElytraGenerator extends ParkourGenerator { protected final ElytraPointType pointType; private final int radius; private final Material material; - private int boostedSectionIdx = Integer.MIN_VALUE; + private final ElytraBoost boost = new ElytraBoost(); public ElytraGenerator(Session session) { this(session, ElytraPointType.CIRCLE); @@ -105,9 +103,7 @@ public class ElytraGenerator extends ParkourGenerator { reset(true); return; } - if (section.isAscending()) { - sustainAscendingPush(player, section, idx); - } + boost.tick(player, section, idx); if (section.isNearKnot(pos, 2, GENERATE_DISTANCE)) { generate(); } @@ -125,7 +121,7 @@ public class ElytraGenerator extends ParkourGenerator { blockChanger.clear(); movementScore = 0; startTime = null; - boostedSectionIdx = Integer.MIN_VALUE; + boost.reset(); if (regenerate) { if (player != null && playerSpawn != null) { player.teleport(playerSpawn.toLocation(player.getWorld())); @@ -180,20 +176,4 @@ public class ElytraGenerator extends ParkourGenerator { } return progress > 0 && (!player.isGliding() || !target.getValue().isNearPoint(pos, (int) progress, radius)); } - - private void sustainAscendingPush(Player player, ElytraSection section, int idx) { - final Vector pos = player.getLocation().toVector(); - if (pos.getX() < section.beginning().getX() || pos.getX() >= section.end().getX()) { - return; - } - if (boostedSectionIdx != idx) { - boostedSectionIdx = idx; - player.setGliding(true); - } - final Vector direction = section.end().clone().subtract(section.beginning()).normalize(); - if (direction.lengthSquared() < EPSILON) { - return; - } - player.setVelocity(player.getVelocity().add(direction.multiply(SUSTAINED_BOOST))); - } } diff --git a/src/main/java/dev/loki/loparkour/elytra/generator/ObstacleGenerator.java b/src/main/java/dev/loki/loparkour/elytra/generator/ObstacleGenerator.java new file mode 100644 index 0000000..4a1c231 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/generator/ObstacleGenerator.java @@ -0,0 +1,198 @@ +package dev.loki.loparkour.elytra.generator; + +import dev.loki.loparkour.config.core.Config; +import dev.loki.loparkour.elytra.section.ElytraPointType; +import dev.loki.loparkour.elytra.section.ElytraSection; +import dev.loki.loparkour.session.core.Session; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Elytra mode that places client-side obstacles on the course which the + * player has to fly around. Obstacles are spoofed via + * {@link dev.loki.loparkour.elytra.section.ClientBlockChanger#sendNow} and + * undone with AIR the same way, so no server chunk is dirtied or pinned. + */ +public class ObstacleGenerator extends ElytraGenerator { + + private static final int DEFAULT_RADIUS = 3; + private static final int OBSTACLE_STEP = 3; + private static final int OBSTACLE_KNOT = 3; + + private final Map> obstacles = new HashMap<>(); + private final int radius; + + public ObstacleGenerator(Session session) { + super(session); + radius = Config.GENERATION.getInt("elytra.radius", DEFAULT_RADIUS); + } + + @Override + public void generate() { + super.generate(); + final int lastIdx = sections.keySet().stream().max(Integer::compareTo).orElse(-1); + final ElytraSection section = sections.get(lastIdx); + if (section == null) { + return; + } + generateObstacle(lastIdx, section, 1); + generateObstacle(lastIdx, section, OBSTACLE_KNOT); + } + + private void generateObstacle(int idx, ElytraSection section, int knotIdx) { + final Obstacle obstacle = Obstacle.values()[ThreadLocalRandom.current().nextInt(Obstacle.values().length)]; + final List points = obstacle.getPoints(section.getKnot(knotIdx), radius); + if (points.isEmpty()) { + return; + } + final Player player = getPlayer(); + if (player == null) { + return; + } + blockChanger.sendNow(player, points, Material.YELLOW_CONCRETE); + obstacles.computeIfAbsent(idx, key -> new ArrayList<>()).addAll(points); + } + + @Override + public void tick() { + super.tick(); + final Player player = getPlayer(); + if (player == null || obstacles.isEmpty()) { + return; + } + final List stale = new ArrayList<>(); + for (final Integer key : obstacles.keySet()) { + if (!sections.containsKey(key)) { + stale.add(key); + } + } + for (final Integer key : stale) { + final List points = obstacles.remove(key); + if (points == null) { + continue; + } + blockChanger.sendNow(player, points, Material.AIR); + } + } + + @Override + public void reset(boolean regenerate) { + super.reset(regenerate); + final Player player = getPlayer(); + if (player != null) { + for (final List points : obstacles.values()) { + blockChanger.sendNow(player, points, Material.AIR); + } + } + obstacles.clear(); + } + + private enum Obstacle { + + PILLARS { + @Override + List getPoints(Vector center, int radius) { + final List points = new ArrayList<>(); + for (int dz = -radius; dz <= radius; dz++) { + if (dz % OBSTACLE_STEP != 0) { + continue; + } + for (int dy = -radius; dy <= radius; dy++) { + final Vector point = center.clone().add(new Vector(0, dy, dz)); + if (point.distance(center) <= radius) { + points.add(point); + } + } + } + return points; + } + }, + + LINES { + @Override + List getPoints(Vector center, int radius) { + final List points = new ArrayList<>(); + for (int dy = -radius; dy <= radius; dy++) { + if (dy % OBSTACLE_STEP != 0) { + continue; + } + for (int dz = -radius; dz <= radius; dz++) { + final Vector point = center.clone().add(new Vector(0, dy, dz)); + if (point.distance(center) <= radius) { + points.add(point); + } + } + } + return points; + } + }, + + HOLE_IN_WALL { + @Override + List getPoints(Vector center, int radius) { + final int offsetY = ThreadLocalRandom.current().nextInt(-2, 3); + final int offsetZ = ThreadLocalRandom.current().nextInt(-2, 3); + final Vector offset = center.clone().add(new Vector(0, offsetY, offsetZ)); + final int radiusSquared = radius * radius; + final List points = new ArrayList<>(); + for (int dy = -radius + 1; dy <= radius; dy++) { + for (int dz = -radius + 1; dz <= radius; dz++) { + final Vector point = center.clone().add(new Vector(0, dy, dz)); + if (point.distanceSquared(center) <= radiusSquared && point.distanceSquared(offset) >= 2) { + points.add(point); + } + } + } + return points; + } + }, + + CIRCLE { + @Override + List getPoints(Vector center, int radius) { + final List points = new ArrayList<>(); + for (int r = 2; r <= radius; r += 2) { + points.addAll(ElytraPointType.CIRCLE.getPoints(center, r)); + } + return points; + } + }, + + DIAMOND { + @Override + List getPoints(Vector center, int radius) { + final List points = new ArrayList<>(); + for (int r = 2; r <= radius; r += OBSTACLE_STEP) { + points.addAll(diamond(center, r)); + } + return points; + } + + private List diamond(Vector center, int radius) { + final List points = new ArrayList<>(); + final Vector newCenter = center.clone().add(new Vector(0, -radius + 2, 0)); + final int r = radius - 2; + final int range = 2 * r; + for (int dy = 0; dy <= range; dy++) { + if (dy < r) { + points.add(newCenter.clone().add(new Vector(0, dy, -dy))); + points.add(newCenter.clone().add(new Vector(0, dy, dy))); + } else { + final int newDz = range - dy; + points.add(newCenter.clone().add(new Vector(0, dy, -newDz))); + points.add(newCenter.clone().add(new Vector(0, dy, newDz))); + } + } + return points; + } + }; + + abstract List getPoints(Vector center, int radius); + } +} diff --git a/src/main/java/dev/loki/loparkour/elytra/mode/ObstacleMode.java b/src/main/java/dev/loki/loparkour/elytra/mode/ObstacleMode.java new file mode 100644 index 0000000..238db94 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/elytra/mode/ObstacleMode.java @@ -0,0 +1,55 @@ +package dev.loki.loparkour.elytra.mode; + +import dev.loki.loparkour.config.locale.Locales; +import dev.loki.loparkour.elytra.generator.ObstacleGenerator; +import dev.loki.loparkour.leaderboard.core.Leaderboard; +import dev.loki.loparkour.mode.base.Mode; +import dev.loki.loparkour.mode.base.ModeMessages; +import dev.loki.loparkour.player.core.ParkourPlayer; +import dev.loki.loparkour.session.core.Session; +import dev.loki.loparkour.util.item.Item; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Elytra mode with client-side obstacles to fly around. + */ +public class ObstacleMode implements Mode { + + private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE); + + @Override + @NotNull + public String getName() { + return "obstacle"; + } + + @Override + @Nullable + public Item getItem(String locale) { + return Locales.getItem(locale, "play.elytra.obstacle"); + } + + @Override + @NotNull + public Leaderboard getLeaderboard() { + return leaderboard; + } + + @Override + public void create(Player player) { + if (!ModeMessages.checkJoiningEnabled(player)) { + return; + } + + final ParkourPlayer pp = ParkourPlayer.getPlayer(player); + if (pp != null && pp.session.generator != null && pp.session.generator.getMode() instanceof ObstacleMode) { + return; + } + + player.closeInventory(); + + Session.create(ObstacleGenerator::new, null, null, player); + } +} diff --git a/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java b/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java index 836f592..6093194 100644 --- a/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java +++ b/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java @@ -1,6 +1,7 @@ package dev.loki.loparkour.elytra.section; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -12,6 +13,7 @@ import org.bukkit.World; import org.bukkit.block.BlockState; import org.bukkit.entity.Player; import org.bukkit.util.Vector; +import org.jetbrains.annotations.NotNull; /** * Pipes pending client-side block updates to the player without leaking server @@ -90,6 +92,28 @@ public class ClientBlockChanger { renderBatches(player, toSend, material); } + /** + * Immediately sends all given positions to the player as the given material, + * bypassing the chunk queue. + * + * @param player the player whose client should be updated. + * @param vectors the positions to update. + * @param material material to display the blocks as. + */ + public void sendNow(@NotNull Player player, @NotNull Collection vectors, @NotNull Material material) { + if (vectors.isEmpty()) { + return; + } + final World world = player.getWorld(); + final List states = new ArrayList<>(vectors.size()); + for (final Vector v : vectors) { + final BlockState state = v.toLocation(world).getBlock().getState(); + state.setType(material); + states.add(state); + } + player.sendBlockChanges(states); + } + /** * Clears the queue without sending anything. */ diff --git a/src/main/java/dev/loki/loparkour/menu/core/Menus.java b/src/main/java/dev/loki/loparkour/menu/core/Menus.java index fc0ca47..c1d645f 100644 --- a/src/main/java/dev/loki/loparkour/menu/core/Menus.java +++ b/src/main/java/dev/loki/loparkour/menu/core/Menus.java @@ -3,6 +3,7 @@ package dev.loki.loparkour.menu.core; import dev.loki.loparkour.menu.community.CommunityMenu; import dev.loki.loparkour.menu.community.LeaderboardsMenu; import dev.loki.loparkour.menu.community.SingleLeaderboardMenu; +import dev.loki.loparkour.menu.lobby.DuelsMenu; import dev.loki.loparkour.menu.lobby.LobbyMenu; import dev.loki.loparkour.menu.lobby.PlayerManagementMenu; import dev.loki.loparkour.menu.play.PlayMenu; @@ -37,6 +38,7 @@ public final class Menus { public static ParkourSettingsMenu PARKOUR_SETTINGS = new ParkourSettingsMenu(); // lobby + public static DuelsMenu DUELS = new DuelsMenu(); public static LobbyMenu LOBBY = new LobbyMenu(); public static PlayerManagementMenu PLAYER_MANAGEMENT = new PlayerManagementMenu(); diff --git a/src/main/java/dev/loki/loparkour/menu/lobby/DuelsMenu.java b/src/main/java/dev/loki/loparkour/menu/lobby/DuelsMenu.java new file mode 100644 index 0000000..4258250 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/menu/lobby/DuelsMenu.java @@ -0,0 +1,123 @@ +package dev.loki.loparkour.menu.lobby; + +import dev.loki.loparkour.config.locale.Locales; +import dev.loki.loparkour.duels.DuelsGenerator; +import dev.loki.loparkour.menu.core.LPMenu; +import dev.loki.loparkour.mode.base.Modes; +import dev.loki.loparkour.player.core.ParkourUser; +import dev.loki.loparkour.session.core.Session; +import dev.loki.loparkour.util.text.ColorUtil; +import dev.loki.loparkour.world.core.Divider; +import dev.lolib.gui.InventoryGUI; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +/** + * Lists open duels sessions and lets the player join one. + */ +public class DuelsMenu extends LPMenu { + + private static final int ROWS = 3; + private static final int ITEMS_PER_PAGE = 7; + private static final int FIRST_SLOT = 10; + private static final int PREV_SLOT = 0; + private static final int NEXT_SLOT = 8; + private static final int SLOT_EMPTY = 13; + private static final int SLOT_CLOSE = 22; + + private int currentPage = 0; + + @Override + public void open(@NotNull Player player) { + open(player, 0); + } + + public void open(@NotNull Player player, int page) { + this.currentPage = page; + final String locale = locale(player); + final String title = Locales.getString(locale, "play.multi.duels.name"); + + final List sessions = collectOpenSessions(); + + final int totalPages = (int) Math.ceil((double) sessions.size() / ITEMS_PER_PAGE); + final int startIndex = currentPage * ITEMS_PER_PAGE; + final int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, sessions.size()); + + InventoryGUI gui = baseGui(title, ROWS); + + if (sessions.isEmpty()) { + gui = gui.setItem(SLOT_EMPTY, localeItem(player, "play.multi.duels.menu_empty")); + } else { + for (int i = startIndex; i < endIndex; i++) { + final Session session = sessions.get(i); + final int slot = FIRST_SLOT + (i - startIndex); + gui = gui.setItem(slot, sessionItem(player, session), e -> { + Modes.DUELS.join(player, session); + player.closeInventory(); + }); + } + } + + if (currentPage > 0) { + gui = gui.setItem(PREV_SLOT, backItem(player), e -> open(player, currentPage - 1)); + } + + if (currentPage < totalPages - 1) { + gui = gui.setItem(NEXT_SLOT, nextItem(player), e -> open(player, currentPage + 1)); + } + + gui.setItem(SLOT_CLOSE, closeButton(player), e -> player.closeInventory()) + .open(player); + } + + private List collectOpenSessions() { + final int maxPlayers = Modes.DUELS.getMaxPlayers(); + final List sessions = new ArrayList<>(); + for (final Session session : Divider.SECTIONS.keySet()) { + if (isOpenForJoining(session, maxPlayers)) { + sessions.add(session); + } + } + return sessions; + } + + private boolean isOpenForJoining(Session session, int maxPlayers) { + return session.generator instanceof DuelsGenerator generator + && generator.allowJoining + && session.getPlayers().size() < maxPlayers; + } + + private ItemStack sessionItem(@NotNull Player player, @NotNull Session session) { + final DuelsGenerator generator = (DuelsGenerator) session.generator; + final ItemStack item = new ItemStack(Material.PLAYER_HEAD); + applySkullOwner(player, item, generator.owner); + + final ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ColorUtil.color(generator.owner.getName())); + item.setItemMeta(meta); + } + return item; + } + + private void applySkullOwner(@NotNull Player player, @NotNull ItemStack skull, @NotNull ParkourUser owner) { + if (ParkourUser.isBedrockPlayer(player)) { + return; + } + if (owner.getName() == null || owner.getName().startsWith(".")) { + return; + } + final SkullMeta meta = (SkullMeta) skull.getItemMeta(); + if (meta != null) { + meta.setOwningPlayer(owner.player); + skull.setItemMeta(meta); + } + } +} diff --git a/src/main/java/dev/loki/loparkour/mode/base/Modes.java b/src/main/java/dev/loki/loparkour/mode/base/Modes.java index 3c5f475..96a5a64 100644 --- a/src/main/java/dev/loki/loparkour/mode/base/Modes.java +++ b/src/main/java/dev/loki/loparkour/mode/base/Modes.java @@ -5,6 +5,7 @@ import dev.loki.loparkour.duels.DuelsMode; import dev.loki.loparkour.elytra.mode.CloseMode; import dev.loki.loparkour.elytra.mode.ElytraMode; import dev.loki.loparkour.elytra.mode.MinSpeedMode; +import dev.loki.loparkour.elytra.mode.ObstacleMode; import dev.loki.loparkour.elytra.mode.SpeedDemonMode; import dev.loki.loparkour.elytra.mode.TimeTrialMode; import dev.loki.loparkour.mode.impl.barrier.InvisibleBarrierMode; @@ -32,6 +33,7 @@ public final class Modes { public static MinSpeedMode MIN_SPEED; public static TimeTrialMode TIME_TRIAL; public static CloseMode CLOSE; + public static ObstacleMode OBSTACLE; public static DuelsMode DUELS; public static void init() { @@ -47,6 +49,7 @@ public final class Modes { MIN_SPEED = (MinSpeedMode) Registry.getMode("minspeed"); TIME_TRIAL = (TimeTrialMode) Registry.getMode("timetrial"); CLOSE = (CloseMode) Registry.getMode("close"); + OBSTACLE = (ObstacleMode) Registry.getMode("obstacle"); DUELS = (DuelsMode) Registry.getMode("duels"); } } diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml index 0adefa3..cf9b484 100644 --- a/src/main/resources/locales/en.yml +++ b/src/main/resources/locales/en.yml @@ -73,6 +73,11 @@ play: name: "�D4FF&lClose" lore: "񢩈Fly through flat rings||񢩈placed close together." + obstacle: + material: "barrier" + name: "�D4FF&lObstacle" + lore: "񢩈Fly around the obstacles||񢩈spawned on your course." + spectator: name: "&#FFFFFFSpectator" @@ -105,6 +110,8 @@ play: name: "�D4FF&lDuels" lore: "" go: "7FF55&lGO!" + name: "&#FFFFFFDuels" + menu_empty: "&#A0A0A0No open duels right now." overview: "&#FFD700&l%s &#A0A0A0won the duel! Time: &#FFFFFF%s" place: "HB3#%s &#A0A0A0%s 񢩈- &#A0A0A0%s" victory: "7FF55&lVICTORY!" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 52e1b0a..1438637 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -72,6 +72,11 @@ play: name: "�D4FF&lБлизко" lore: "񢩈Летай сквозь плоские кольца||񢩈расположенные вплотную." + obstacle: + material: "barrier" + name: "�D4FF&lПрепятствия" + lore: "񢩈Облетай препятствия,||񢩈появляющиеся на трассе." + spectator: name: "&#FFFFFFНаблюдатель" @@ -104,6 +109,8 @@ play: name: "�D4FF&lДуэли" lore: "" go: "7FF55&lВПЕРЁД!" + name: "&#FFFFFFДуэли" + menu_empty: "&#A0A0A0Сейчас нет открытых дуэлей." overview: "&#FFD700&l%s &#A0A0A0победил в дуэли! Время: &#FFFFFF%s" place: "HB3#%s &#A0A0A0%s 񢩈- &#A0A0A0%s" victory: "7FF55&lПОБЕДА!" diff --git a/src/test/java/dev/loki/loparkour/duels/SingleDuelsGeneratorTest.java b/src/test/java/dev/loki/loparkour/duels/SingleDuelsGeneratorTest.java new file mode 100644 index 0000000..3b27e7e --- /dev/null +++ b/src/test/java/dev/loki/loparkour/duels/SingleDuelsGeneratorTest.java @@ -0,0 +1,48 @@ +package dev.loki.loparkour.duels; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +class SingleDuelsGeneratorTest { + + private static final int COLOR_COUNT = 16; + + @Test + void testPlayerIndexProducesBlockDataForEveryColor() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.createBlockData(any(Material.class))).thenReturn(mock(BlockData.class)); + + final SingleDuelsGenerator generator = mock(SingleDuelsGenerator.class, CALLS_REAL_METHODS); + for (int i = 0; i < COLOR_COUNT; i++) { + generator.setPlayerIndex(i); + assertNotNull(generator.selectBlockData(), "color index " + i + " must produce block data"); + } + } + } + + @Test + void testSelectBlockDataFallsBackWithoutPlayerIndex() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.createBlockData(any(Material.class))).thenReturn(mock(BlockData.class)); + + final SingleDuelsGenerator generator = mock(SingleDuelsGenerator.class, CALLS_REAL_METHODS); + assertNotNull(generator.selectBlockData(), "default block data must never be null"); + } + } + + @Test + void testGetPlayersEmptyWithoutOwnedPlayer() { + final SingleDuelsGenerator generator = mock(SingleDuelsGenerator.class, CALLS_REAL_METHODS); + assertTrue(generator.getPlayers().isEmpty(), "no owned player must yield an empty player list"); + } +} diff --git a/src/test/java/dev/loki/loparkour/elytra/section/ElytraKnotDirectorTest.java b/src/test/java/dev/loki/loparkour/elytra/section/ElytraKnotDirectorTest.java new file mode 100644 index 0000000..f196321 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/elytra/section/ElytraKnotDirectorTest.java @@ -0,0 +1,64 @@ +package dev.loki.loparkour.elytra.section; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ElytraKnotDirectorTest { + + private static final long SEED_A = 42L; + private static final long SEED_B = 7L; + private static final int SAMPLES = 1000; + private static final int COMPARE_SAMPLES = 20; + private static final int DX_MEAN = 75; + private static final int DX_SD = 15; + private static final int DY_SD = 3; + private static final int DZ_SD = 35; + private static final int SD_MULTIPLIER = 2; + + @Test + void testOffsetsStayWithinDistributionBounds() { + final ElytraKnotDirector director = + new ElytraKnotDirector(new Random(SEED_A), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS); + final int minDx = DX_MEAN - SD_MULTIPLIER * DX_SD; + final int maxDx = DX_MEAN + SD_MULTIPLIER * DX_SD; + final int minDy = ElytraKnotDirector.DEFAULT_VERTICAL_BIAS - SD_MULTIPLIER * DY_SD; + final int maxDy = ElytraKnotDirector.DEFAULT_VERTICAL_BIAS + SD_MULTIPLIER * DY_SD; + final int minDz = -SD_MULTIPLIER * DZ_SD; + final int maxDz = SD_MULTIPLIER * DZ_SD; + + for (int i = 0; i < SAMPLES; i++) { + final Vector offset = director.nextOffset(); + final int dx = (int) offset.getX(); + final int dy = (int) offset.getY(); + final int dz = (int) offset.getZ(); + assertTrue(dx >= minDx && dx <= maxDx, "dx out of bounds: " + dx); + assertTrue(dy >= minDy && dy <= maxDy, "dy out of bounds: " + dy); + assertTrue(dz >= minDz && dz <= maxDz, "dz out of bounds: " + dz); + } + } + + @Test + void testDifferentSeedsProduceDifferentOffsets() { + final ElytraKnotDirector first = + new ElytraKnotDirector(new Random(SEED_A), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS); + final ElytraKnotDirector second = + new ElytraKnotDirector(new Random(SEED_B), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS); + final List firstOffsets = collect(first, COMPARE_SAMPLES); + final List secondOffsets = collect(second, COMPARE_SAMPLES); + assertNotEquals(firstOffsets, secondOffsets, "different seeds must produce different offset sequences"); + } + + private List collect(ElytraKnotDirector director, int count) { + final List result = new ArrayList<>(); + for (int i = 0; i < count; i++) { + result.add(director.nextOffset()); + } + return result; + } +} diff --git a/src/test/java/dev/loki/loparkour/elytra/section/ElytraPointTypeTest.java b/src/test/java/dev/loki/loparkour/elytra/section/ElytraPointTypeTest.java new file mode 100644 index 0000000..85fdf9d --- /dev/null +++ b/src/test/java/dev/loki/loparkour/elytra/section/ElytraPointTypeTest.java @@ -0,0 +1,69 @@ +package dev.loki.loparkour.elytra.section; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ElytraPointTypeTest { + + private static final Vector CENTER = new Vector(0, 0, 0); + private static final double BLOCK_CENTER = 0.5; + private static final int RADIUS = 3; + private static final int SYMMETRY_DIVISOR = 8; + private static final int FLAT_POINT_COUNT = 3; + private static final int FLAT_Z_OFFSET = 1; + private static final int CIRCLE_HEIGHT_OFFSET = -1; + private static final int FLAT_HEIGHT_OFFSET = -5; + + @Test + void testCircleRadiusZeroYieldsSinglePoint() { + final List points = ElytraPointType.CIRCLE.getPoints(CENTER, 0); + assertEquals(1, points.size(), "radius zero must yield exactly one point"); + assertEquals(CENTER, points.get(0)); + } + + @Test + void testCircleRadiusThreeIsSymmetricAroundBlockCenter() { + final List points = ElytraPointType.CIRCLE.getPoints(CENTER, RADIUS); + assertEquals(0, points.size() % SYMMETRY_DIVISOR, "point count must be a multiple of 8"); + + final Set offsets = new HashSet<>(); + for (final Vector p : points) { + assertEquals(BLOCK_CENTER, p.getX(), "x coordinate stays on the block center"); + final int dy = (int) Math.round(p.getY() - BLOCK_CENTER); + final int dz = (int) Math.round(p.getZ() - BLOCK_CENTER); + assertTrue(Math.abs(dy) <= RADIUS, "y offset must not exceed radius"); + assertTrue(Math.abs(dz) <= RADIUS, "z offset must not exceed radius"); + offsets.add(new Offset(dy, dz)); + } + + for (final Offset offset : offsets) { + assertTrue(offsets.contains(new Offset(-offset.dy, offset.dz)), "circle must be symmetric on y"); + assertTrue(offsets.contains(new Offset(offset.dy, -offset.dz)), "circle must be symmetric on z"); + assertTrue(offsets.contains(new Offset(-offset.dy, -offset.dz)), "circle must be point symmetric"); + } + } + + @Test + void testFlatYieldsExactlyThreeCollinearPoints() { + final List points = ElytraPointType.FLAT.getPoints(CENTER, RADIUS); + assertEquals(FLAT_POINT_COUNT, points.size(), "flat expansion must yield exactly three points"); + assertEquals(new Vector(0, 0, -FLAT_Z_OFFSET), points.get(0)); + assertEquals(new Vector(0, 0, 0), points.get(1)); + assertEquals(new Vector(0, 0, FLAT_Z_OFFSET), points.get(2)); + } + + @Test + void testHeightOffsetsMatchContract() { + assertEquals(CIRCLE_HEIGHT_OFFSET, ElytraPointType.CIRCLE.heightOffset); + assertEquals(FLAT_HEIGHT_OFFSET, ElytraPointType.FLAT.heightOffset); + } + + private record Offset(int dy, int dz) { + } +} diff --git a/src/test/java/dev/loki/loparkour/elytra/section/ElytraSectionTest.java b/src/test/java/dev/loki/loparkour/elytra/section/ElytraSectionTest.java new file mode 100644 index 0000000..d6ff879 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/elytra/section/ElytraSectionTest.java @@ -0,0 +1,111 @@ +package dev.loki.loparkour.elytra.section; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ElytraSectionTest { + + private static final Vector START = new Vector(0, 100, 0); + private static final long SEED = 42L; + private static final int KNOT_COUNT = 5; + private static final int LAST_KNOT_INDEX = 4; + private static final int MIN_EXPECTED_POINTS = 20; + private static final int PIPE_RADIUS = 3; + private static final int CHUNK_SHIFT = 4; + private static final int MAX_POINTS = 1000; + private static final double NEAR_DISTANCE = 1.0; + private static final double FAR_OFFSET = 100.0; + private static final double CLONE_SENTINEL = 999.0; + + private ElytraSection section; + + @BeforeEach + void setUp() { + section = new ElytraSection(START, new Random(SEED), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS); + } + + @Test + void testKnotsCount() { + assertNotNull(section.getKnot(LAST_KNOT_INDEX), "last knot must exist"); + assertNotNull(section.beginning(), "beginning knot must exist"); + assertNotNull(section.end(), "end knot must exist"); + assertThrows(IndexOutOfBoundsException.class, () -> section.getKnot(KNOT_COUNT), + "there must be exactly five knots"); + } + + @Test + void testSplinePointsMonotonicByX() { + final List points = collectPoints(); + assertTrue(points.size() > MIN_EXPECTED_POINTS, "spline must contain more than 20 points"); + for (int i = 0; i < points.size() - 1; i++) { + assertTrue(points.get(i).getX() < points.get(i + 1).getX(), "x must increase at point " + i); + } + } + + @Test + void testIsNearPoint() { + final Vector own = section.getPoint(0); + assertTrue(section.isNearPoint(own, 0, NEAR_DISTANCE), "own position must be near the point"); + + final Vector far = own.clone().add(new Vector(FAR_OFFSET, 0, 0)); + assertFalse(section.isNearPoint(far, 0, NEAR_DISTANCE), "distant position must not be near the point"); + } + + @Test + void testIsAscending() { + final ElytraSection climbing = + new ElytraSection(START, new Random(SEED), ElytraKnotDirector.ASCENDING_VERTICAL_BIAS); + final ElytraSection diving = + new ElytraSection(START, new Random(SEED), ElytraKnotDirector.DEFAULT_VERTICAL_BIAS); + assertTrue(climbing.isAscending(), "positive bias must produce an ascending section"); + assertFalse(diving.isAscending(), "negative bias must produce a descending section"); + } + + @Test + void testGenerateBlocksGroupsByChunk() { + final Map> blocks = section.generateBlocks(ElytraPointType.CIRCLE, PIPE_RADIUS); + assertFalse(blocks.isEmpty(), "block map must not be empty"); + for (final Map.Entry> entry : blocks.entrySet()) { + assertFalse(entry.getValue().isEmpty(), "every chunk bucket must be non-empty"); + for (final Vector block : entry.getValue()) { + assertEquals(entry.getKey().intValue(), block.getBlockX() >> CHUNK_SHIFT, + "bucket key must equal the chunk x of every block"); + } + } + } + + @Test + void testBeginningAndEndReturnClones() { + final Vector start = section.beginning(); + final Vector finish = section.end(); + start.setX(CLONE_SENTINEL); + finish.setY(CLONE_SENTINEL); + assertNotEquals(CLONE_SENTINEL, section.beginning().getX(), "mutating beginning must not affect the section"); + assertNotEquals(CLONE_SENTINEL, section.end().getY(), "mutating end must not affect the section"); + } + + private List collectPoints() { + final List result = new ArrayList<>(); + for (int i = 0; i < MAX_POINTS; i++) { + try { + result.add(section.getPoint(i)); + } catch (IndexOutOfBoundsException ignore) { + break; + } + } + return result; + } +}