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
This commit is contained in:
loki5512344 2026-08-05 21:49:11 +02:00
parent 5707cf2d24
commit 3b5d3d97d5
Signed by: boba
GPG key ID: 253067914055423B
19 changed files with 804 additions and 28 deletions

View file

@ -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> 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> 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");
}
}

View file

@ -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<Vector> firstOffsets = collect(first, COMPARE_SAMPLES);
final List<Vector> secondOffsets = collect(second, COMPARE_SAMPLES);
assertNotEquals(firstOffsets, secondOffsets, "different seeds must produce different offset sequences");
}
private List<Vector> collect(ElytraKnotDirector director, int count) {
final List<Vector> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
result.add(director.nextOffset());
}
return result;
}
}

View file

@ -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<Vector> 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<Vector> points = ElytraPointType.CIRCLE.getPoints(CENTER, RADIUS);
assertEquals(0, points.size() % SYMMETRY_DIVISOR, "point count must be a multiple of 8");
final Set<Offset> 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<Vector> 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) {
}
}

View file

@ -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<Vector> 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<Integer, Set<Vector>> blocks = section.generateBlocks(ElytraPointType.CIRCLE, PIPE_RADIUS);
assertFalse(blocks.isEmpty(), "block map must not be empty");
for (final Map.Entry<Integer, Set<Vector>> 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<Vector> collectPoints() {
final List<Vector> result = new ArrayList<>();
for (int i = 0; i < MAX_POINTS; i++) {
try {
result.add(section.getPoint(i));
} catch (IndexOutOfBoundsException ignore) {
break;
}
}
return result;
}
}