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:
parent
5707cf2d24
commit
3b5d3d97d5
19 changed files with 804 additions and 28 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue