fix jump slab

This commit is contained in:
loki 2026-03-17 13:36:23 +01:00
parent d9eb747c1b
commit 978f739045
43 changed files with 5685 additions and 832 deletions

230
release/GUIDE.md Normal file
View file

@ -0,0 +1,230 @@
# LoParkour — Гайд по патчам
Все файлы в этом архиве заменяют оригинальные файлы проекта **1 к 1 по пути**.
Просто скопируй их поверх своего src/ и пересобери проект.
---
## Что уже исправлено (этот архив)
### 🔴 Патч 1 — SQL Injection (КРИТИЧНО)
**Файлы:** `storage/StorageSQL.java`, `storage/SQLQueryExecutor.java`, `storage/SQLMigrationManager.java`
Оригинальный код вставлял имена игроков, стили, локали напрямую в SQL строки через `.formatted()`.
Игрок с ником `'; DROP TABLE loparkour-leaderboard-default; --` мог уничтожить таблицу лидерборда.
**Что сделано:** Все запросы с пользовательскими данными переписаны на `?` плейсхолдеры.
JDBC-драйвер сам экранирует значения — инъекция физически невозможна.
---
### 🔴 Патч 2 — ElytraMode полная переработка
**Файлы:** `mode/ElytraMode.java`, `mode/ElytraGenerator.java`, `resources/config.yml`, `resources/locales/en.yml`
Оригинальный ElytraMode был просто обычным паркуром с элитрой в инвентаре.
Кольца лежали горизонтально (как блины), хитбокс был кубом 5×5×5,
cooldown фейерверка не работал (показывал сообщение но не блокировал).
**Что сделано:**
- Генерируются кольца в воздухе по плавной кривой (настраивается в config.yml)
- Кольца стоят вертикально, перпендикулярно направлению полёта
- Детект пролёта через плоскость кольца (dot product, а не куб)
- Firework перехватывается через `PlayerInteractEvent` — реально блокируется до использования
- Boost выдаётся вручную через `setVelocity()` + визуальный фейерверк без урона
- Action bar: счёт + дистанция до следующего кольца + cooldown
---
### 🟠 Патч 3 — SpeedrunMode не работал вообще
**Файл:** `mode/SpeedrunMode.java`
`onBlockTouch()` был объявлен но **нигде не вызывался** — блоки никогда не исчезали.
Speedrun был идентичен обычному режиму.
**Что сделано:** Подключился через `score()` override — вызывается в момент когда
игрок встаёт на новый блок. Исправлено деление на 50 которое давало 0 тиков при
малых значениях. Warning task теперь отменяется при reset().
---
### 🟠 Патч 4 — CoopMode показывался в одиночном меню
**Файл:** `mode/CoopMode.java`
`CoopMode implements Mode` вместо `MultiMode` — поэтому висел в меню рядом с Default/Speedrun.
`SingleMenu` фильтрует `instanceof MultiMode`, но кооп им не был.
**Что сделано:** `CoopMode implements MultiMode`. `getItem()` возвращает `null`.
`create()` открывает лобби вместо создания сессии соло.
Исправлен двойной счётчик (`sharedScore` дублировал `state.score`).
`fall()` больше не чистит стейт который уже сброшен внутри `super.fall()`.
---
### 🟠 Патч 5 — RaceMode off-by-one + ActionBar конфликт
**Файл:** `mode/RaceMode.java`
Проверка финиша была в `tick()` — срабатывала на тик позже нужного.
ActionBar обновлялся каждый тик конфликтуя с parent.
`reset(false)` вызывался с задержкой без null-check — NPE если игрок вышел.
**Что сделано:** Финиш проверяется в `score()` override — точно в момент достижения цели.
Прогресс-бар обновляется только при новом очке. Добавлен guard `!state.stopped`.
---
## Что ещё нужно исправить (следующие шаги)
### 🔴 Приоритет 1 — Ghost файлы по имени игрока (path traversal + коллизии)
**Файл:** `ghost/GhostManager.java`
```java
// ПРОБЛЕМА:
File file = new File(modeFolder, data.getPlayerName() + ".ghost");
```
Два игрока с одинаковым именем затирают друг друга.
Игрок с именем `../../config` может писать за пределы папки плагина.
**Как чинить:** Называть файлы по UUID игрока, не по имени.
Хранить имя внутри файла (оно уже там есть в `GhostData`).
---
### 🟡 Приоритет 2 — Ghost не пишет всех игроков в мультиплеере
**Файл:** `generator/lifecycle/GeneratorLifecycle.java`
`GhostRecorder` записывает только `generator.player` (первый игрок сессии).
В CoopMode и RaceMode остальные игроки не записываются.
**Как чинить:** Recorder на каждого игрока отдельно, или писать только в single-player режимах.
---
### 🟡 Приоритет 3 — `disable()` глотает все исключения
**Файл:** `LoParkour.java`
```java
} catch (Throwable ignored) { }
```
Если при выключении сервера не сохранятся данные — никто не узнает.
**Как чинить:** Минимум логировать: `catch (Throwable t) { getLogger().severe(...) }`.
---
### 🟡 Приоритет 4 — `cleanupDistantBlocks()` вызывается дважды за тик
**Файл:** `generator/lifecycle/GeneratorLifecycle.java`
Вызывается явно в `tick()` И через отдельный `cleanupTask` таймер одновременно.
**Как чинить:** Убрать явный вызов из `tick()`, оставить только таймер.
---
### 🟡 Приоритет 5 — `new Random()` в горячих методах
**Файл:** `generator/jump/BlockPlacer.java`
```java
pool.get(new java.util.Random().nextInt(pool.size())) // внутри tryGenerateSchematic()
new java.util.Random().nextInt(rotations.length) // внутри rotatedPaste()
```
Каждый вызов создаёт новый объект Random. Дорого и неправильно.
**Как чинить:** Один `private static final ThreadLocalRandom RNG = ThreadLocalRandom.current()`
на уровне класса.
---
### 🟡 Приоритет 6 — `System.err.println` в JumpOffsetGenerator
**Файл:** `generator/jump/JumpOffsetGenerator.java`
```java
System.err.println("Warning: Unexpected jump height ...");
```
В Bukkit плагинах нельзя писать в System.err напрямую.
**Как чинить:** `LoParkour.getPlugin().getLogger().warning(...)`.
---
### 🟢 Приоритет 7 — Пустой javadoc в LoParkour.java
**Файл:** `LoParkour.java`
Висит осиротевший javadoc от удалённого метода `getLogging()`. Не баг, просто мусор.
---
## Порядок следующих патчей (рекомендация)
1. Ghost path traversal (безопасность)
2. `disable()` silent catch (надёжность)
3. `cleanupDistantBlocks` двойной вызов (производительность)
4. `new Random()` в горячих путях (производительность)
5. Ghost мультиплеер (функциональность)
---
## Патчи добавленные в эту версию архива
### 🔴 Патч 6 — Ghost path traversal + коллизии по имени (БЕЗОПАСНОСТЬ)
**Файлы:** `ghost/GhostData.java`, `ghost/GhostManager.java`, `ghost/GhostRecorder.java`, `generator/lifecycle/GeneratorLifecycle.java`
Файлы назывались `playerName.ghost`. Два игрока с одинаковым ником затирали друг друга.
Игрок с именем `../../config` мог писать файлы за пределы папки плагина (path traversal).
**Что сделано:**
- Файлы теперь называются `uuid.ghost` — уникально и filesystem-safe
- `GhostData` хранит UUID (добавлен в конструктор и в бинарный формат)
- `GhostRecorder.stopRecording()` принимает UUID + имя
- `GhostManager.sanitizeMode()` чистит имя режима от слэшей
- Старые файлы v1 (без UUID) автоматически удаляются при загрузке с логом
- Формат файла версионирован (int VERSION=2 в начале)
---
### 🟡 Патч 7 — `cleanupDistantBlocks()` вызывался дважды за тик
**Файл:** `generator/lifecycle/GeneratorLifecycle.java`
Был явный вызов в `tick()` И таймер `cleanupTask` — двойная работа каждый тик.
**Что сделано:** Убран явный вызов из `tick()`. Только таймер.
---
### 🟡 Патч 8 — `new Random()` в горячих методах BlockPlacer
**Файл:** `generator/jump/BlockPlacer.java`
`new java.util.Random()` создавался внутри `tryGenerateSchematic()` и `rotatedPaste()` при каждом вызове.
**Что сделано:** Заменено на `ThreadLocalRandom.current().nextInt()`.
---
### 🟡 Патч 9 — `System.err.println` в JumpOffsetGenerator
**Файл:** `generator/jump/JumpOffsetGenerator.java`
**Что сделано:** Заменено на `LoParkour.getPlugin().getLogger().warning(...)`.
---
### 🟡 Патч 10 — `disable()` глотал все исключения молча
**Файл:** `LoParkour.java`
`catch (Throwable ignored) {}` — если данные не сохранялись при выключении сервера, никто не узнавал.
**Что сделано:** `catch (Throwable t)` с логированием и `t.printStackTrace()`.
---
## Оставшиеся задачи
### 🟡 Ghost мультиплеер
`GhostRecorder` пишет только `generator.player` (первый игрок сессии).
В CoopMode/RaceMode остальные игроки не записываются.
**Решение:** Либо отдельный рекордер на каждого, либо запись только в single-player режимах.
### 🟢 Осиротевший javadoc в LoParkour.java
Комментарий от удалённого метода `getLogging()` — просто мусор, не баг.

View file

@ -0,0 +1,269 @@
package dev.loki.loparkour;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.hook.HoloHook;
import dev.loki.loparkour.hook.PAPIHook;
import dev.loki.loparkour.mode.DefaultMode;
import dev.loki.loparkour.mode.ElytraMode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.mode.SpectatorMode;
import dev.loki.loparkour.mode.SpeedrunMode;
import dev.loki.loparkour.mode.RaceMode;
import dev.loki.loparkour.mode.CoopMode;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.player.UserRegistry;
import dev.loki.loparkour.reward.Rewards;
import dev.loki.loparkour.schematic.lpschem.LPSchematicManager;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.world.World;
import dev.lolib.core.LoPlugin;
import dev.lolib.scheduler.Scheduler;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.bstats.bukkit.Metrics;
import org.bstats.charts.SimplePie;
import org.bstats.charts.SingleLineChart;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Main class of LoParkour
*
* @author loki
*/
public final class LoParkour extends LoPlugin {
public static final String NAME = "&#FF6464<bold>LoParkour<reset>";
public static final String PREFIX = NAME + " &#404040» &#A0A0A0";
private static final Gson gson = new GsonBuilder()
.setPrettyPrinting()
.excludeFieldsWithoutExposeAnnotation()
.create();
public static Gson getGson() {
return gson;
}
private static LoParkour instance;
private static LPSchematicManager schematicManager;
@Nullable
private static PAPIHook placeholderHook;
public static void log(String message) {
if (Config.CONFIG.getBoolean("debug")) {
LoParkour.getPlugin().getLogger().info("[Debug] " + message);
}
}
/**
* @param child The file name.
* @return A file from within the plugin folder.
*/
public static File getInFolder(String child) {
return new File(instance.getDataFolder(), child);
}
/**
* @return This plugin's {@link Logging} instance.
*/
/**
* @return The plugin instance.
*/
public static LoParkour getPlugin() {
return instance;
}
@Nullable
public static PAPIHook getPlaceholderHook() {
return placeholderHook;
}
public static LPSchematicManager getSchematicManager() {
return schematicManager;
}
@Override
public void enable() {
instance = this; // must be first — Config enum accesses instance inside reload()
loadConfigs();
loadSchematics();
registerModes();
registerHooks();
setupWorld();
registerEventsAndCommands();
setupMetrics();
}
private void loadConfigs() {
Config.reload(true);
}
private void loadSchematics() {
schematicManager = new LPSchematicManager();
// Create default island schematic if it doesn't exist
File schematicsFolder = getInFolder("schematics-new");
File islandFile = new File(schematicsFolder, "island.lpschem");
if (!islandFile.exists()) {
try {
createDefaultIslandSchematic(islandFile);
getLogger().info("Created default island schematic");
} catch (Exception ex) {
getLogger().severe("Failed to create default island schematic: " + ex.getMessage());
ex.printStackTrace();
}
}
schematicManager.loadAll();
}
private void createDefaultIslandSchematic(File file) throws Exception {
// Create a larger 9x5x9 island for better visibility and safety
List<String> palette = new ArrayList<>();
palette.add("minecraft:air");
palette.add("minecraft:stone");
palette.add("minecraft:diamond_block");
palette.add("minecraft:emerald_block");
int width = 9, height = 5, length = 9;
int[] blocks = new int[width * height * length];
// Fill everything with air first
for (int i = 0; i < blocks.length; i++) {
blocks[i] = 0;
}
// Create a solid stone platform at y=1 (7x7 platform)
for (int z = 1; z < 8; z++) {
for (int x = 1; x < 8; x++) {
int index = x + (z * width) + (1 * width * length);
blocks[index] = 1; // stone
}
}
// Place diamond block (player spawn) at center
int centerX = 4, centerZ = 4;
int diamondIndex = centerX + (centerZ * width) + (1 * width * length);
blocks[diamondIndex] = 2; // diamond_block - player spawns here
// Place emerald block (parkour start) to the right of spawn
int emeraldIndex = (centerX + 1) + (centerZ * width) + (1 * width * length);
blocks[emeraldIndex] = 3; // emerald_block - parkour starts here
dev.loki.loparkour.schematic.lpschem.SchematicMetadata metadata =
new dev.loki.loparkour.schematic.lpschem.SchematicMetadata("island", "LoParkour", 0.0);
metadata.addTag("spawn");
metadata.addTag("island");
dev.loki.loparkour.schematic.lpschem.SchematicDimensions dimensions =
new dev.loki.loparkour.schematic.lpschem.SchematicDimensions(width, height, length);
// Markers point to the blocks where player spawns and parkour begins
dev.loki.loparkour.schematic.lpschem.SchematicMarkers markers =
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers(
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers.Vector3i(centerX, 1, centerZ),
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers.Vector3i(centerX + 1, 1, centerZ)
);
dev.loki.loparkour.schematic.lpschem.LPSchematic schematic =
new dev.loki.loparkour.schematic.lpschem.LPSchematic(metadata, dimensions, palette, blocks, markers);
schematic.save(file);
}
private void registerModes() {
Registry.register(new DefaultMode());
Registry.register(new SpectatorMode());
Registry.register(new SpeedrunMode());
Registry.register(new ElytraMode());
Registry.register(new RaceMode());
Registry.register(new CoopMode());
Modes.init();
}
private void registerHooks() {
if (getServer().getPluginManager().isPluginEnabled("HolographicDisplays")) {
getLogger().info("Registered Holographic Displays hook");
HoloHook.init();
}
if (getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) {
getLogger().info("Registered PlaceholderAPI hook");
placeholderHook = new PAPIHook();
placeholderHook.register();
}
if (Config.CONFIG.getBoolean("bungeecord.enabled")) {
getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord");
getLogger().info("Registered BungeeCord hook");
}
}
private void setupWorld() {
if (!Config.CONFIG.getBoolean("joining")) {
return;
}
// World creation MUST happen on the main thread (Bukkit API requirement)
try {
loLogger().info("Creating parkour world...");
World.create();
loLogger().info("Parkour world created successfully!");
} catch (Exception ex) {
loLogger().error("Failed to create parkour world", ex);
}
}
private void registerEventsAndCommands() {
dev.lolib.gui.GUIManager.init(this); // required for LoLib GUI click handling
var pm = getServer().getPluginManager();
pm.registerEvents(new dev.loki.loparkour.listener.PlayerConnectionListener(), this);
pm.registerEvents(new dev.loki.loparkour.listener.ParkourRestrictionListener(), this);
pm.registerEvents(new dev.loki.loparkour.listener.SchematicWandListener(), this);
LoParkourCommand cmd = new LoParkourCommand();
getCommand("LoParkour").setExecutor(cmd);
getCommand("LoParkour").setTabCompleter(cmd);
}
private void setupMetrics() {
Metrics metrics = new Metrics(this, 29754);
metrics.addCustomChart(new SimplePie("using_sql", () -> Boolean.toString(Option.SQL)));
metrics.addCustomChart(new SimplePie("using_rewards", () -> Boolean.toString(Rewards.REWARDS_ENABLED)));
metrics.addCustomChart(new SimplePie("locale_count", () -> Integer.toString(Locales.locales.size())));
metrics.addCustomChart(new SingleLineChart("player_joins", () -> {
int joins = UserRegistry.getJoinCount();
return joins;
}));
}
@Override
public void disable() {
try {
for (ParkourUser user : ParkourUser.getUsers()) {
ParkourUser.leave(user);
}
// write all LoParkour gamemodes
Modes.DEFAULT.getLeaderboard().write(false);
Storage.close();
World.delete();
} catch (Throwable t) {
getLogger().severe("Error during plugin shutdown: " + t.getMessage());
t.printStackTrace();
}
}
}

View file

@ -0,0 +1,281 @@
package dev.loki.loparkour.generator.jump;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourBlockGenerateEvent;
import dev.loki.loparkour.api.event.ParkourSchematicGenerateEvent;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.GeneratorOption;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.style.Style;
import dev.loki.loparkour.util.Probs;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Fence;
import org.bukkit.block.data.type.GlassPane;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Level;
/**
* Handles block selection, placement, and schematic generation.
* Extracted from {@link ParkourGenerator}.
*/
public class BlockPlacer {
private final ParkourGenerator g;
public BlockPlacer(ParkourGenerator generator) {
this.g = generator;
}
// ── Public entry points ────────────────────────────────────────────────────
/** Generates one block or schematic ahead. */
public void generate() {
if (g.state.waitForSchematicCompletion) return;
Map<ParkourGenerator.BlockGenerationType, Double> chances = new HashMap<>(g.state.defaultChances);
if (g.state.schematicCooldown > 0
|| g.generatorOptions.contains(GeneratorOption.DISABLE_SCHEMATICS)
|| g.profile.get("schematicDifficulty").asDouble() == 0.0
|| !g.state.schematicBlocks.isEmpty()) {
chances.remove(ParkourGenerator.BlockGenerationType.SCHEMATIC);
}
if (!g.profile.get("useSpecialBlocks").asBoolean()) {
chances.remove(ParkourGenerator.BlockGenerationType.SPECIAL);
}
if (chances.isEmpty()) chances.put(ParkourGenerator.BlockGenerationType.DEFAULT, 1.0);
ParkourGenerator.BlockGenerationType jump = Probs.random(chances);
if (jump == ParkourGenerator.BlockGenerationType.SCHEMATIC && tryGenerateSchematic()) return;
placeNormalBlock(jump);
}
/** Generates {@code amount+1} blocks. */
public void generate(int amount) {
for (int i = 0; i <= amount; i++) generate();
}
/** Seeds the history and generates the initial lead. */
public void generateFirst(Location spawn, Location blockSpawn) {
g.state.playerSpawn = spawn;
g.state.lastStandingPlayerLocation = spawn;
g.state.blockSpawn = blockSpawn;
g.state.history.add(blockSpawn.getBlock());
generate(g.profile.get("blockLead").asInt());
}
/** Clears schematic blocks and resets cooldown. */
public void deleteSchematic() {
if (!g.state.deleteSchematic) return;
g.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
g.state.schematicBlocks.clear();
g.state.deleteSchematic = false;
g.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
}
// ── Block selection ────────────────────────────────────────────────────────
protected List<Block> selectBlocks() {
int height = Probs.random(g.state.heightChances);
int distance = Probs.random(g.state.distanceChances);
return List.of(selectNext(g.getLatest(), distance, height));
}
protected Block selectNext(Block current, int distance, int height) {
JumpDirector director = new JumpDirector(
BoundingBox.of(g.zone[0], g.zone[1]), g.getLatest().getLocation().toVector());
g.state.heading = director.getRecommendedHeading(g.state.heading);
height = director.getRecommendedHeight(height);
// Restrict jumps after special blocks to prevent impossible jumps
Material lastType = g.getLatest().getType();
switch (lastType) {
case SMOOTH_QUARTZ_SLAB -> {
height = Math.min(height, 0); // Can't jump up from slab
distance = Math.min(distance, 3); // Limit distance
}
case GLASS_PANE -> {
height = Math.min(height, 0); // Can't jump up from pane
distance = Math.min(distance, 3); // Limit distance
}
case OAK_FENCE, BIRCH_FENCE, SPRUCE_FENCE, DARK_OAK_FENCE,
JUNGLE_FENCE, ACACIA_FENCE, MANGROVE_FENCE, CHERRY_FENCE,
CRIMSON_FENCE, WARPED_FENCE, NETHER_BRICK_FENCE -> {
height = Math.min(height, 0); // Can't jump up from fence
distance = Math.min(distance, 3); // Limit distance
}
case PACKED_ICE -> {
distance = Math.min(distance, 3); // Ice is slippery, limit distance
}
case OAK_TRAPDOOR, BIRCH_TRAPDOOR, SPRUCE_TRAPDOOR, DARK_OAK_TRAPDOOR,
JUNGLE_TRAPDOOR, ACACIA_TRAPDOOR, MANGROVE_TRAPDOOR, CHERRY_TRAPDOOR,
CRIMSON_TRAPDOOR, WARPED_TRAPDOOR, IRON_TRAPDOOR -> {
height = Math.min(height, 0); // Can't jump up from trapdoor
distance = Math.min(distance, 2); // Very limited distance
}
case LADDER -> {
height = Math.min(height, 1); // Limited height from ladder
distance = Math.min(distance, 2); // Very limited distance
}
}
// Clamp values to valid ranges for JumpOffsetGenerator
height = Math.max(-2, Math.min(1, height));
distance = Math.max(1, Math.min(4, distance));
if (height > 0) distance = Math.max(distance - height, 1);
double sd = g.generatorOptions.contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? 0.5 : 1;
int randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
Vector offset = g.state.heading.clone().multiply(distance + 1).setY(height);
if (offset.getX() == 0) offset.setX(randomOffset);
else offset.setZ(randomOffset);
offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection()));
Block candidate = current.getLocation().add(offset).getBlock();
// Retry with JumpValidator if jump is impossible (up to 10 attempts)
JumpValidator validator = new JumpValidator();
int attempts = 0;
while (!validator.canJump(current.getLocation(), candidate.getLocation()) && attempts < 10) {
// Reduce distance and height to make jump easier
distance = Math.max(1, distance - 1);
height = Math.max(-1, height - 1);
randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
offset = g.state.heading.clone().multiply(distance + 1).setY(height);
if (offset.getX() == 0) offset.setX(randomOffset);
else offset.setZ(randomOffset);
offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection()));
candidate = current.getLocation().add(offset).getBlock();
attempts++;
}
return candidate;
}
protected BlockData selectBlockData() {
Style style = Registry.getStyle(g.profile.get("style").value());
if (style == null) {
g.profile.set("style", Registry.getStyles().stream().findFirst().orElseThrow().getName());
return selectBlockData();
}
return style.getNext().createBlockData();
}
// ── Internal ───────────────────────────────────────────────────────────────
private void placeNormalBlock(ParkourGenerator.BlockGenerationType jump) {
List<Block> blocks = selectBlocks();
if (blocks.isEmpty()) {
LoParkour.getPlugin().getLogger().severe("No blocks to generate!");
return;
}
List<Block> placed = new ArrayList<>();
for (Block block : blocks) {
BlockData data;
// Select JumpType based on chances (70% normal, 30% special types)
JumpType jumpType = selectJumpType();
if (jumpType != JumpType.NORMAL && !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) {
// Use JumpType material
data = jumpType.getRandomMaterial().createBlockData();
} else if (jump == ParkourGenerator.BlockGenerationType.SPECIAL
&& !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL)) {
data = Probs.random(g.state.specialChances);
} else {
data = selectBlockData();
}
if (data instanceof Fence) block = block.getLocation().subtract(0, 1, 0).getBlock();
block.setBlockData(data, data instanceof Fence || data instanceof GlassPane);
placed.add(block);
}
new ParkourBlockGenerateEvent(placed, g, g.player).call();
g.effects.particles(placed);
g.effects.sound(placed);
g.state.history.addAll(placed);
g.state.schematicCooldown--;
}
private JumpType selectJumpType() {
// Simple chances: 70% normal, 5% each for special types
Map<JumpType, Double> chances = new HashMap<>();
chances.put(JumpType.NORMAL, 70.0);
chances.put(JumpType.NEO_JUMP, 5.0);
chances.put(JumpType.HEAD_HITTER, 5.0);
chances.put(JumpType.FENCE_JUMP, 10.0);
chances.put(JumpType.TRAPDOOR_JUMP, 5.0);
chances.put(JumpType.LADDER_JUMP, 5.0);
return Probs.random(chances);
}
private boolean tryGenerateSchematic() {
var manager = LoParkour.getSchematicManager();
if (manager == null) return false;
var all = manager.getAllSchematics();
if (all.isEmpty()) return false;
double diff = g.profile.get("schematicDifficulty").asDouble();
var candidates = all.values().stream()
.filter(s -> s.getMetadata() != null && Math.abs(s.getMetadata().getDifficulty() - diff) <= 0.26)
.toList();
var pool = candidates.isEmpty() ? new ArrayList<>(all.values()) : candidates;
var schematic = pool.get(ThreadLocalRandom.current().nextInt(pool.size()));
Location origin = g.getLatest().getLocation().add(g.state.heading.clone().multiply(2));
List<Block> placed = rotatedPaste(schematic, origin);
if (placed.isEmpty()) return false;
g.state.schematicBlocks.addAll(placed);
g.state.history.addAll(placed);
g.state.waitForSchematicCompletion = true;
g.state.deleteSchematic = false;
new ParkourSchematicGenerateEvent(schematic, g, g.player).call();
return true;
}
private @NotNull List<Block> rotatedPaste(
dev.loki.loparkour.schematic.lpschem.LPSchematic schematic, Location location) {
int[] rotations = {0, 90, 180, 270};
int rotation = rotations[ThreadLocalRandom.current().nextInt(rotations.length)];
org.bukkit.World world = location.getWorld();
if (world == null) return List.of();
if (rotation == 0) return schematic.paste(location, world);
try {
var t = new dev.loki.loparkour.schematic.lpschem.SchematicTransformer(schematic);
return t.rotate(rotation).paste(location, world);
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().log(Level.WARNING, "Could not rotate schematic", ex);
return schematic.paste(location, world);
}
}
private double angleInY(Vector a, Vector b) {
return Math.atan2(a.getX() * b.getZ() - a.getZ() * b.getX(), a.dot(b));
}
}

View file

@ -0,0 +1,78 @@
package dev.loki.loparkour.generator.jump;
import dev.loki.loparkour.util.Probs;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
/**
* Generates a random offset (the sidestep) for a parkour block.
* Source: <a href="https://i.imgur.com/RPevMfX.png">here</a>.
*/
public class JumpOffsetGenerator {
private final int maxOffset;
/**
* Constructor.
*
* @param y The height difference.
*/
public JumpOffsetGenerator(int y, int distance) {
this.maxOffset = switch (y) {
case 1 -> switch (distance) {
case 1 -> 4;
case 2 -> 3;
default -> 2;
};
case 0 -> switch (distance) {
case 1, 2 -> 4;
case 3 -> 3;
default -> 2;
};
case -1 -> switch (distance) {
case 1, 2 -> 5;
case 3 -> 4;
default -> 3;
};
case -2 -> switch (distance) {
case 1, 2 -> 5;
default -> 4;
};
default -> {
// Fallback for unexpected values
dev.loki.loparkour.LoParkour.getPlugin().getLogger()
.warning("Unexpected jump height " + y + ", using default maxOffset=2");
yield 2;
}
};
}
/**
* @param mean The mean (average) value for the offset. Usually 0 to avoid parkour going only left or right.
* @param standardDeviation The standard deviation.
* @param random The random instance.
* @return A random jump-able offset.
*/
public int getRandomOffset(double mean, double standardDeviation, Random random) {
Map<Double, Double> distribution = java.util.stream.IntStream.range(-maxOffset, maxOffset + 1)
.boxed()
.collect(Collectors.toMap(
offset -> (double) offset,
offset -> Probs.normalpdf(mean, standardDeviation, (double) offset)
));
return (int) (double) Probs.random(distribution, random);
}
/**
* @param mean The mean (average) value for the offset. Usually 0 to avoid parkour going only left or right.
* @param standardDeviation The standard deviation.
* @return A random jump-able offset.
*/
public int getRandomOffset(double mean, double standardDeviation) {
return getRandomOffset(mean, standardDeviation, ThreadLocalRandom.current());
}
}

View file

@ -0,0 +1,265 @@
package dev.loki.loparkour.generator.lifecycle;
import dev.lolib.scheduler.Scheduler;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.ghost.GhostData;
import dev.loki.loparkour.ghost.GhostManager;
import dev.loki.loparkour.ghost.GhostRecorder;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.reward.Rewards;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Iterator;
/**
* Handles parkour lifecycle: tick, fall, reset, cleanup.
*/
public class GeneratorLifecycle {
private final ParkourGenerator generator;
private dev.lolib.scheduler.ScheduledTask cleanupTask;
private GhostRecorder ghostRecorder;
private GhostManager ghostManager;
public GeneratorLifecycle(ParkourGenerator generator) {
this.generator = generator;
if (Config.CONFIG.getBoolean("ghost-mode.enabled")) {
this.ghostRecorder = new GhostRecorder();
this.ghostManager = new GhostManager();
this.ghostManager.loadGhosts(generator.getMode().getName());
}
}
public void startTick() {
generator.task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 0, 1);
cleanupTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::cleanupDistantBlocks, 0, Option.CLEANUP_INTERVAL);
// Start ghost recording if enabled
if (ghostRecorder != null && ghostManager != null) {
ghostRecorder.startRecording(generator.player.getLocation());
// Spawn top ghosts
if (generator.state.playerSpawn != null) {
ghostManager.spawnGhosts(
generator.getMode().getName(),
generator.state.playerSpawn,
generator.player.player.getWorld()
);
}
}
}
public void tick() {
if (generator.state.stopped) {
generator.task.cancel();
if (cleanupTask != null) cleanupTask.cancel();
if (ghostManager != null) ghostManager.stopAllGhosts();
return;
}
generator.getPlayers().forEach(p -> {
updateVisualTime(p, p.selectedTime);
p.updateScoreboard(generator);
p.player.setSaturation(20);
// Record ghost frame
if (ghostRecorder != null && ghostRecorder.isRecording()) {
ghostRecorder.recordFrame(p.getLocation());
}
});
generator.getSpectators().forEach(ParkourSpectator::update);
if (generator.player.getLocation().getWorld() != generator.state.lastStandingPlayerLocation.getWorld()) return;
if (generator.player.getLocation().subtract(generator.state.lastStandingPlayerLocation).getY() < -10) {
fall();
return;
}
Block below = blockBelow();
if (below == null) return;
handleSchematicEndBlock(below);
// Clean up schematic blocks that are behind the player
if (!generator.state.schematicBlocks.isEmpty()) {
int currentIdx = generator.state.history.indexOf(below);
if (currentIdx > 0) {
generator.state.schematicBlocks.removeIf(b -> {
int blockIdx = generator.state.history.indexOf(b);
if (blockIdx >= 0 && blockIdx < currentIdx - 5) {
b.setType(Material.AIR);
return true;
}
return false;
});
}
}
// Reset schematic wait if player is far from schematic blocks
if (generator.state.waitForSchematicCompletion && !generator.state.schematicBlocks.isEmpty()) {
boolean nearSchematic = generator.state.schematicBlocks.stream()
.anyMatch(b -> b.getLocation().distance(generator.player.getLocation()) < 15);
if (!nearSchematic) {
generator.state.waitForSchematicCompletion = false;
// Force delete schematic blocks
generator.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
generator.state.schematicBlocks.clear();
generator.state.deleteSchematic = false;
generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
}
}
if (!generator.state.history.contains(below)) return;
int idx = generator.state.history.indexOf(below);
int delta = idx - generator.state.lastPositionIndexPlayer;
if (delta <= 0) return;
generator.state.lastStandingPlayerLocation = generator.player.getLocation();
int lead = generator.profile.get("blockLead").asInt();
if (generator.state.history.size() - idx <= lead) {
generator.generate(lead - (generator.state.history.size() - idx));
}
generator.state.lastPositionIndexPlayer = idx;
removeTrailBlocks(idx);
generator.placer.deleteSchematic();
int pts = Config.CONFIG.getBoolean("scoring.all-points") ? delta : 1;
for (int i = 0; i < pts; i++) score();
if (generator.state.start == null) generator.state.start = Instant.now();
}
private void handleSchematicEndBlock(Block below) {
if (generator.state.schematicBlocks.contains(below)
&& below.getType() == Material.RED_WOOL
&& !generator.state.deleteSchematic) {
for (int i = 0; i < generator.profile.get("schematicDifficulty").asDouble() * 15; i++) {
score();
}
generator.state.waitForSchematicCompletion = false;
generator.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
generator.generate(generator.profile.get("blockLead").asInt());
generator.state.deleteSchematic = true;
}
}
private @Nullable Block blockBelow() {
Location loc = generator.player.getLocation().subtract(0, 1, 0);
Block b = loc.getBlock();
if (b.getType() == Material.AIR) {
if (loc.subtract(0, 0.5, 0).getBlock().getType() == Material.AIR) return null;
b = loc.getBlock();
}
return b;
}
private void removeTrailBlocks(int idx) {
for (int i = idx - ParkourGenerator.BLOCK_TRAIL - 1; i >= idx - 4 * ParkourGenerator.BLOCK_TRAIL; i--) {
if (i > 0) generator.state.history.get(i).setType(Material.AIR);
}
}
private void updateVisualTime(ParkourPlayer p, int selectedTime) {
int t = 18000 + selectedTime;
if (t >= 24000) t -= 24000;
p.player.setPlayerTime(t, false);
}
public void score() {
generator.state.score++;
generator.state.totalScore++;
checkRewards();
new ParkourScoreEvent(generator.player).call();
}
private void checkRewards() {
if (!Rewards.REWARDS_ENABLED || generator.state.score == 0) return;
if (Rewards.SCORE_REWARDS.containsKey(generator.state.score)) {
Rewards.SCORE_REWARDS.get(generator.state.score).forEach(r -> r.execute(generator.player, generator.getMode()));
}
int intervalScore = Config.CONFIG.getBoolean("scoring.rewards-use-total-score")
? generator.state.totalScore
: generator.state.score;
for (int interval : Rewards.INTERVAL_REWARDS.keySet()) {
if (intervalScore % interval == 0) {
Rewards.INTERVAL_REWARDS.get(interval).forEach(r -> r.execute(generator.player, generator.getMode()));
}
}
String key = Integer.toString(generator.state.score);
if (Rewards.ONE_TIME_REWARDS.containsKey(generator.state.score)
&& !generator.player.collectedRewards.contains(key)) {
Rewards.ONE_TIME_REWARDS.get(generator.state.score).forEach(r -> r.execute(generator.player, generator.getMode()));
generator.player.collectedRewards.add(key);
}
}
public void fall() {
new ParkourFallEvent(generator.player).call();
// Save ghost if score is good enough
if (ghostRecorder != null && ghostManager != null && ghostRecorder.isRecording()) {
String modeName = generator.getMode().getName();
int score = generator.state.score;
if (ghostManager.shouldRecordGhost(modeName, score)) {
// Pass UUID as well as name — GhostManager names files by UUID
GhostData ghostData = ghostRecorder.stopRecording(
generator.player.getUUID(),
generator.player.getName(),
score
);
ghostManager.saveGhost(modeName, ghostData);
generator.player.sendTranslated("ghost.saved", Integer.toString(score));
}
}
if (ghostManager != null) {
ghostManager.stopAllGhosts();
}
generator.reset(true);
}
protected void cleanupDistantBlocks() {
if (generator.state.history.size() < Option.BLOCK_CLEANUP_DISTANCE * 2) return;
Location loc = generator.player.getLocation();
int removed = 0;
Iterator<Block> it = generator.state.history.iterator();
while (it.hasNext()) {
Block b = it.next();
if (b.getLocation().distance(loc) > Option.BLOCK_CLEANUP_DISTANCE) {
b.setType(Material.AIR, false);
it.remove();
removed++;
} else break;
}
if (removed > 0) {
generator.state.lastPositionIndexPlayer = Math.max(0, generator.state.lastPositionIndexPlayer - removed);
}
}
}

View file

@ -0,0 +1,102 @@
package dev.loki.loparkour.ghost;
import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Immutable snapshot of a player's run: who ran it, their score, and every recorded frame.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Files were named {@code playerName.ghost} — two players with the same name
* overwrote each other, and a name like {@code ../../config} caused path traversal.
* UUID is now stored and used as the filename instead.</li>
* <li>File format is backwards-compatible: UUID is written first (new field),
* then the existing layout. Old files without a UUID are handled in
* {@link #loadFromFile} by catching the format mismatch and returning null.</li>
* </ul>
*/
public class GhostData {
/** Binary format version — bump when changing the serialised layout. */
private static final int FORMAT_VERSION = 2;
private final UUID playerUuid;
private final String playerName;
private final int score;
private final List<GhostFrame> frames;
public GhostData(@NotNull UUID playerUuid, @NotNull String playerName,
int score, @NotNull List<GhostFrame> frames) {
this.playerUuid = playerUuid;
this.playerName = playerName;
this.score = score;
this.frames = new ArrayList<>(frames);
}
// ── Getters ───────────────────────────────────────────────────────────────
public UUID getPlayerUuid() { return playerUuid; }
public String getPlayerName() { return playerName; }
public int getScore() { return score; }
public List<GhostFrame> getFrames() { return frames; }
// ── Serialisation ─────────────────────────────────────────────────────────
public void saveToFile(@NotNull File file) throws IOException {
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(file)))) {
out.writeInt(FORMAT_VERSION); // version header
out.writeUTF(playerUuid.toString()); // UUID (filename-safe, collision-free)
out.writeUTF(playerName); // display name (for the ghost label)
out.writeInt(score);
out.writeInt(frames.size());
for (GhostFrame frame : frames) {
out.writeLong(frame.getTimestamp());
out.writeDouble(frame.getX());
out.writeDouble(frame.getY());
out.writeDouble(frame.getZ());
out.writeFloat(frame.getYaw());
out.writeFloat(frame.getPitch());
}
}
}
/**
* Loads a ghost from file.
*
* @return The loaded data, or {@code null} if the file is an old v1 format
* (no version header / UUID) — caller should delete and ignore it.
*/
public static GhostData loadFromFile(@NotNull File file) throws IOException {
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new FileInputStream(file)))) {
int version = in.readInt();
if (version != FORMAT_VERSION) {
// Old format (v1) started with readUTF for playerName, not an int version.
// Those files are stale — return null so the manager can discard them.
return null;
}
UUID uuid = UUID.fromString(in.readUTF());
String name = in.readUTF();
int score = in.readInt();
int frameCount = in.readInt();
List<GhostFrame> frames = new ArrayList<>(frameCount);
for (int i = 0; i < frameCount; i++) {
frames.add(new GhostFrame(
in.readLong(),
in.readDouble(), in.readDouble(), in.readDouble(),
in.readFloat(), in.readFloat()));
}
return new GhostData(uuid, name, score, frames);
}
}
}

View file

@ -0,0 +1,162 @@
package dev.loki.loparkour.ghost;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import org.bukkit.Location;
import org.bukkit.World;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.util.*;
/**
* Loads, saves, and spawns ghost runs per mode.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Files were named {@code playerName.ghost} — collisions between players sharing
* a name, and path traversal via names like {@code ../../config}.
* Now named {@code uuid.ghost} — guaranteed unique and filesystem-safe.</li>
* <li>When evicting the lowest ghost, the old file was looked up by player name
* which failed if the name had changed since the run was saved.</li>
* <li>Old v1 files (no UUID) are detected via {@link GhostData#loadFromFile}
* returning null — they are deleted automatically on first load.</li>
* </ul>
*/
public class GhostManager {
private static final int MAX_GHOSTS_PER_MODE = 3;
private final File ghostFolder;
private final Map<String, List<GhostData>> ghostsByMode = new HashMap<>();
private final List<GhostPlayer> activeGhosts = new ArrayList<>();
public GhostManager() {
this.ghostFolder = LoParkour.getInFolder("ghosts");
if (!ghostFolder.exists()) {
ghostFolder.mkdirs();
}
}
// ── Loading ───────────────────────────────────────────────────────────────
public void loadGhosts(@NotNull String mode) {
File modeFolder = new File(ghostFolder, sanitizeMode(mode));
if (!modeFolder.exists()) return;
File[] files = modeFolder.listFiles((dir, name) -> name.endsWith(".ghost"));
if (files == null) return;
List<GhostData> ghosts = new ArrayList<>();
for (File file : files) {
try {
GhostData data = GhostData.loadFromFile(file);
if (data == null) {
// Old v1 format — delete and skip
LoParkour.getPlugin().getLogger().info(
"Deleting outdated ghost file (old format): " + file.getName());
file.delete();
continue;
}
ghosts.add(data);
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe(
"Failed to load ghost file " + file.getName() + ": " + e.getMessage());
}
}
ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
ghostsByMode.put(mode, ghosts);
}
// ── Saving ────────────────────────────────────────────────────────────────
public void saveGhost(@NotNull String mode, @NotNull GhostData data) {
File modeFolder = new File(ghostFolder, sanitizeMode(mode));
if (!modeFolder.exists()) modeFolder.mkdirs();
List<GhostData> ghosts = new ArrayList<>(ghostsByMode.getOrDefault(mode, new ArrayList<>()));
ghosts.add(data);
ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
// Evict lowest-score ghosts beyond the cap
while (ghosts.size() > MAX_GHOSTS_PER_MODE) {
GhostData evicted = ghosts.remove(ghosts.size() - 1);
// File is named by UUID — always finds the right file regardless of name changes
File evictedFile = ghostFile(modeFolder, evicted);
if (evictedFile.exists()) evictedFile.delete();
}
ghostsByMode.put(mode, ghosts);
// Save new ghost — named by UUID, not by player name
File file = ghostFile(modeFolder, data);
try {
data.saveToFile(file);
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe(
"Failed to save ghost for " + data.getPlayerName() + ": " + e.getMessage());
}
}
// ── Spawning ──────────────────────────────────────────────────────────────
public void spawnGhosts(@NotNull String mode, @NotNull Location startLocation,
@NotNull World world) {
if (!Config.CONFIG.getBoolean("ghost-mode.enabled")) return;
stopAllGhosts();
List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) return;
int showTop = Math.min(Config.CONFIG.getInt("ghost-mode.show-top"), ghosts.size());
for (int i = 0; i < showTop; i++) {
GhostPlayer ghost = new GhostPlayer(ghosts.get(i), world);
ghost.spawn(startLocation);
activeGhosts.add(ghost);
}
}
public void stopAllGhosts() {
activeGhosts.forEach(GhostPlayer::stop);
activeGhosts.clear();
}
// ── Queries ───────────────────────────────────────────────────────────────
@Nullable
public GhostData getTopGhost(@NotNull String mode) {
List<GhostData> ghosts = ghostsByMode.get(mode);
return (ghosts == null || ghosts.isEmpty()) ? null : ghosts.get(0);
}
public List<GhostData> getTopGhosts(@NotNull String mode, int count) {
List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) return Collections.emptyList();
return Collections.unmodifiableList(ghosts.subList(0, Math.min(count, ghosts.size())));
}
public boolean shouldRecordGhost(@NotNull String mode, int score) {
List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) return true;
return score > ghosts.get(ghosts.size() - 1).getScore();
}
// ── Internals ─────────────────────────────────────────────────────────────
/** Returns the ghost file for a run — always UUID-based, never name-based. */
private File ghostFile(File modeFolder, GhostData data) {
return new File(modeFolder, data.getPlayerUuid().toString() + ".ghost");
}
/**
* Strips any path separators from mode names so they can't be used to
* escape the ghosts directory (e.g. mode name "../../etc").
*/
private static String sanitizeMode(String mode) {
return mode.replaceAll("[/\\\\.]", "_");
}
}

View file

@ -0,0 +1,78 @@
package dev.loki.loparkour.ghost;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.EntityType;
import org.jetbrains.annotations.NotNull;
/**
* Spawns and animates an ArmorStand that replays a {@link GhostData} run.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Duplicate import of {@code dev.lolib.scheduler.Scheduler} — removed.</li>
* <li>Duplicate field declaration {@code dev.lolib.scheduler.ScheduledTask task}
* shadowed the already-imported type — removed the fully-qualified duplicate.</li>
* </ul>
*/
public class GhostPlayer {
private final GhostData data;
private final World world;
private ArmorStand entity;
private ScheduledTask task;
private int currentFrame = 0;
private long startTime;
public GhostPlayer(@NotNull GhostData data, @NotNull World world) {
this.data = data;
this.world = world;
}
public void spawn(@NotNull Location startLocation) {
if (data.getFrames().isEmpty()) return;
entity = (ArmorStand) world.spawnEntity(startLocation, EntityType.ARMOR_STAND);
entity.setVisible(false);
entity.setGravity(false);
entity.setInvulnerable(true);
entity.setCustomName("§7" + data.getPlayerName() + " §8(Ghost)");
entity.setCustomNameVisible(true);
startTime = System.currentTimeMillis();
currentFrame = 0;
task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::update, 0, 1);
}
private void update() {
if (entity == null || !entity.isValid()) {
stop();
return;
}
long elapsed = System.currentTimeMillis() - startTime;
while (currentFrame < data.getFrames().size()) {
GhostFrame frame = data.getFrames().get(currentFrame);
if (frame.getTimestamp() > elapsed) break;
entity.teleport(frame.toLocation(world));
currentFrame++;
}
if (currentFrame >= data.getFrames().size()) stop();
}
public void stop() {
if (task != null) { task.cancel(); task = null; }
if (entity != null) { entity.remove(); entity = null; }
}
public boolean isActive() { return entity != null && entity.isValid(); }
public String getPlayerName() { return data.getPlayerName(); }
public int getScore() { return data.getScore(); }
}

View file

@ -0,0 +1,61 @@
package dev.loki.loparkour.ghost;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Records a player's movement as a sequence of {@link GhostFrame}s.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>{@code stopRecording} took only {@code playerName} — now also takes {@code playerUuid}
* so {@link GhostData} can store it and {@link GhostManager} can name files by UUID.</li>
* </ul>
*/
public class GhostRecorder {
private static final double MIN_MOVEMENT = 0.1;
private final List<GhostFrame> frames = new ArrayList<>();
private Location lastLocation;
private long startTime;
private boolean recording = false;
public void startRecording(@NotNull Location startLocation) {
frames.clear();
lastLocation = startLocation.clone();
startTime = System.currentTimeMillis();
recording = true;
frames.add(new GhostFrame(0, startLocation.clone()));
}
public void recordFrame(@NotNull Location currentLocation) {
if (!recording) return;
if (lastLocation.distance(currentLocation) < MIN_MOVEMENT) return;
long timestamp = System.currentTimeMillis() - startTime;
frames.add(new GhostFrame(timestamp, currentLocation.clone()));
lastLocation = currentLocation.clone();
}
/**
* Stops recording and returns the completed ghost data.
*
* @param playerUuid UUID of the player — used as the filename in {@link GhostManager}.
* @param playerName Display name shown on the ghost label in-game.
* @param score The score achieved this run.
*/
public GhostData stopRecording(@NotNull UUID playerUuid,
@NotNull String playerName,
int score) {
recording = false;
return new GhostData(playerUuid, playerName, score, frames);
}
public boolean isRecording() { return recording; }
public int getFrameCount() { return frames.size(); }
}

View file

@ -0,0 +1,192 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Co-op Mode — multiple players share one session and one score.
*
* <p>Implements {@link MultiMode} so it is excluded from the single-player
* mode selection menu in {@link dev.loki.loparkour.menu.play.SingleMenu}.
* Players join via /parkour coop or through a lobby invite flow.</p>
*
* <p>Problems fixed vs previous version:
* <ul>
* <li>Did NOT implement MultiMode → showed up in single-player menu</li>
* <li>CoopGenerator was a private inner class — inaccessible from join/leave</li>
* <li>sharedScore was a separate field that duplicated state.score</li>
* <li>fall() called super.fall() THEN tried to clear state that was already reset</li>
* <li>playerContributions never cleared between falls correctly</li>
* <li>create(Player) silently created a solo session instead of rejecting</li>
* </ul>
*/
public class CoopMode implements MultiMode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
// Active coop sessions — used so join() can add to an existing one
// Key = session, tracked via Divider automatically; this is just for lookup
// We look up by existing player's session instead.
@Override
@NotNull
public String getName() {
return "coop";
}
/**
* Returns null so this mode never appears in any menu item list.
* Players join via lobby invite or command only.
*/
@Override
@Nullable
public dev.loki.loparkour.util.Item getItem(String locale) {
return null;
}
@Override
@NotNull
public Leaderboard getLeaderboard() {
return leaderboard;
}
/**
* Called when a player tries to start coop solo from a command/menu.
* Opens the lobby menu so they can invite others — does NOT create a session here.
* A session is created only once a second player joins via {@link #join}.
*/
@Override
public void create(Player player) {
if (!Config.CONFIG.getBoolean("joining")) {
player.sendMessage("§cJoining is currently disabled.");
return;
}
// Opening lobby menu lets player invite others — actual session created in join()
Menus.LOBBY.open(player);
}
/**
* Adds a player to an existing coop session, or creates a new session if
* the target player is not yet in one.
*/
@Override
public void join(Player joiningPlayer, Session session) {
if (!Config.CONFIG.getBoolean("joining")) {
joiningPlayer.sendMessage("§cJoining is currently disabled.");
return;
}
if (session.generator == null) return;
// Register the joining player into the existing session
ParkourPlayer pp = ParkourUser.register(joiningPlayer, session);
session.addPlayers(pp);
pp.updateGeneratorSettings(session.generator);
// Inform everyone
session.getPlayers().forEach(p ->
p.sendTranslated("modes.coop.player_joined", joiningPlayer.getName()));
}
/**
* Removes a player from a coop session.
* If the last player leaves, the session ends normally.
*/
@Override
public void leave(Player player, Session session) {
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp == null) return;
ParkourUser.leave(pp);
}
@Override
public int getMaxPlayers() {
return Config.CONFIG.getInt("modes.coop.max-players");
}
// ── CoopGenerator ─────────────────────────────────────────────────────────
/**
* Package-accessible so Session.create() lambda can reference it,
* and so join() can cast generator when needed.
*/
static class CoopGenerator extends ParkourGenerator {
/** Individual block contributions per player UUID. */
private final Map<UUID, Integer> contributions = new HashMap<>();
public CoopGenerator(@NotNull Session session) {
super(session);
session.getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0));
}
@Override
protected void score() {
super.score(); // increments state.score and totalScore
// Track which player was on the block (primary player for single, varies in multi)
contributions.merge(player.getUUID(), 1, Integer::sum);
// Milestone every 50 points
if (state.score % 50 == 0) {
getPlayers().forEach(pp -> {
pp.player.sendTitle(
"§6Team: §e" + state.score,
"§aKeep it up!",
5, 40, 10);
pp.player.playSound(pp.getLocation(),
Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.5f);
});
}
}
/**
* When a new player joins mid-game, add them to contributions map.
*/
public void onPlayerJoin(ParkourPlayer pp) {
contributions.putIfAbsent(pp.getUUID(), 0);
}
@Override
protected void fall() {
// Show each player their individual contribution before reset
getPlayers().forEach(pp -> {
int contrib = contributions.getOrDefault(pp.getUUID(), 0);
int pct = state.score > 0 ? (contrib * 100 / state.score) : 0;
pp.sendTranslated("modes.coop.stats",
Integer.toString(state.score),
Integer.toString(contrib),
Integer.toString(pct));
});
super.fall(); // handles reset, leaderboard, etc.
}
@Override
public void reset(boolean regenerate) {
super.reset(regenerate);
contributions.clear();
getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0));
}
@Override
public Mode getMode() {
return Modes.COOP;
}
}
}

View file

@ -0,0 +1,750 @@
package dev.loki.loparkour.mode;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.entity.Firework;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.FireworkMeta;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
/**
* Generator for Elytra mode.
*
* <p>Completely replaces block-based parkour with an aerial ring course:
* <ul>
* <li>Rings are placed ahead along a smooth curved path in the sky</li>
* <li>Each ring is vertical, perpendicular to the flight direction</li>
* <li>Player must fly through the center — detection uses plane-crossing math</li>
* <li>Firework usage is intercepted via event to enforce cooldown properly</li>
* <li>Fall = player touches ground / loses flight / flies too far from course</li>
* </ul>
*/
public class ElytraGenerator extends ParkourGenerator implements Listener {
// ── Constants ─────────────────────────────────────────────────────────────
/** How many rings behind the player to keep before deleting. */
private static final int RING_TRAIL = 3;
/** Ticks between particle ring renders (every 2 ticks = 10fps). */
private static final int RENDER_INTERVAL = 2;
/** Minimum Y height — if player drops below this they failed. */
private static final double MIN_Y_OFFSET = -30.0;
// Config-driven values cached at init
private int ringLead;
private double maxDeviation;
/** Particle count per ring point. */
private static final int PARTICLES_PER_POINT = 1;
/** Number of particle points around each ring. */
private static final int RING_POINTS = 36;
// ── State ─────────────────────────────────────────────────────────────────
private final List<ElytraRing> rings = new ArrayList<>();
private int nextRingIndex = 0; // index of the next ring the player hasn't passed
private int tickCounter = 0;
private Instant startTime = null;
private int score = 0;
private ScheduledTask tickTask;
private ScheduledTask renderTask;
/** Firework cooldown per player UUID in ms. */
private final Map<UUID, Long> fireworkCooldowns = new HashMap<>();
/** Starting Y for fall detection. */
private double spawnY;
// ── Constructor ───────────────────────────────────────────────────────────
public ElytraGenerator(@NotNull Session session) {
super(session);
this.ringLead = Config.CONFIG.getInt("modes.elytra.ring-lead");
this.maxDeviation = Config.CONFIG.getDouble("modes.elytra.max-deviation");
}
// ── Mode identification ───────────────────────────────────────────────────
@Override
public Mode getMode() {
return Modes.ELYTRA;
}
// ── Lifecycle: called by Session after island.build() ────────────────────
/**
* Called once when the generator is set up.
* We skip the block-based generateFirst and do our own aerial setup.
*/
@Override
public void generateFirst(Location spawn, Location blockSpawn) {
spawnY = spawn.getY();
state.playerSpawn = spawn;
state.lastStandingPlayerLocation = spawn;
state.blockSpawn = blockSpawn;
// Seed the ring path starting just in front of spawn
Vector initialHeading = state.heading.clone().normalize();
Location ringOrigin = spawn.clone().add(initialHeading.clone().multiply(10)).add(0, 2, 0);
generateRings(ringOrigin, initialHeading, ringLead);
// Setup player
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
p.setGameMode(GameMode.ADVENTURE);
p.getInventory().clear();
// Elytra chestplate
p.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
// Starting fireworks
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
p.getInventory().setItem(0, makeFirework(amount));
p.getInventory().setHeldItemSlot(0);
// Launch into glide
p.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
p.setGliding(true);
pp.sendTranslated("modes.elytra.start_hint");
}
// Register firework event listener
Bukkit.getPluginManager().registerEvents(this, LoParkour.getPlugin());
// Start ticks
tickTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 1, 1);
renderTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::renderRings, 0, RENDER_INTERVAL);
}
// ── generateFirst is the entry — skip normal generate() completely ────────
@Override
public void generate() { /* not used in elytra mode */ }
@Override
public void generate(int amount) { /* not used */ }
@Override
public void startTick() { /* tick started in generateFirst */ }
// ── Main tick ─────────────────────────────────────────────────────────────
private void tick() {
if (state.stopped) {
stopTasks();
return;
}
tickCounter++;
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
// Ensure player stays gliding
if (!p.isGliding() && p.getGameMode() == GameMode.ADVENTURE) {
// Only force-glide if they're not on ground and have elytra
if (!p.isOnGround()) {
p.setGliding(true);
}
}
// Update scoreboard
pp.updateScoreboard(this);
p.setSaturation(20);
// Action bar: score + next ring distance
updateActionBar(pp);
}
// Fall detection
checkFall();
// Ring crossing detection
checkRingCrossings();
}
// ── Ring generation ───────────────────────────────────────────────────────
/**
* Generates {@code count} new rings starting from {@code origin},
* heading in {@code direction}, with smooth curve randomisation.
*/
private void generateRings(Location origin, Vector direction, int count) {
int distMin = Config.CONFIG.getInt("modes.elytra.ring-distance-min");
int distMax = Config.CONFIG.getInt("modes.elytra.ring-distance-max");
double ringSize = Config.CONFIG.getDouble("modes.elytra.ring-size");
double maxYaw = Config.CONFIG.getDouble("modes.elytra.max-heading-change-horizontal");
double maxPitch = Config.CONFIG.getDouble("modes.elytra.max-heading-change-vertical");
double maxHeight = Config.CONFIG.getDouble("modes.elytra.max-height-above-spawn");
ThreadLocalRandom rng = ThreadLocalRandom.current();
Location cursor = origin.clone();
Vector heading = direction.clone().normalize();
// If we already have rings, continue from the last one
if (!rings.isEmpty()) {
ElytraRing last = rings.get(rings.size() - 1);
cursor = last.center.clone();
heading = last.normal.clone();
}
for (int i = 0; i < count; i++) {
// Distance between rings
double dist = distMin + rng.nextDouble() * (distMax - distMin);
// Smooth heading change using config-defined limits
double yawDelta = Math.toRadians(rng.nextDouble(-maxYaw, maxYaw));
double pitchDelta = Math.toRadians(rng.nextDouble(-maxPitch, maxPitch));
heading = rotateVector(heading, yawDelta, pitchDelta);
heading.normalize();
// New ring center
Location center = cursor.clone().add(heading.clone().multiply(dist));
// Keep rings in a reasonable Y band
double targetY = Math.max(spawnY + 5, Math.min(spawnY + maxHeight, center.getY()));
center.setY(targetY);
rings.add(new ElytraRing(rings.size(), center.clone(), heading.clone(), ringSize));
cursor = center;
}
}
// ── Ring crossing detection ───────────────────────────────────────────────
private void checkRingCrossings() {
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
ParkourPlayer pp = player; // primary player
Location loc = pp.getLocation();
ElytraRing nextRing = rings.get(nextRingIndex);
// Check if player crossed the ring's plane
if (nextRing.hasCrossed(loc, pp.player.getVelocity())) {
if (nextRing.isInside(loc)) {
// Perfect pass — score
onRingPassed(pp, nextRing, true);
} else {
// Crossed the plane but missed the ring hole
onRingMissed(pp, nextRing);
}
nextRingIndex++;
// Generate more rings ahead
int ahead = rings.size() - nextRingIndex;
if (ahead < ringLead) {
generateRings(null, null, ringLead - ahead);
}
// Clean up old rings
cleanOldRings();
}
// Check deviation — if player flew too far off course
double deviation = nextRing.center.distance(loc);
if (deviation > maxDeviation) {
triggerFall("flew off course");
}
}
private void onRingPassed(ParkourPlayer pp, ElytraRing ring, boolean centered) {
score++;
if (startTime == null) startTime = Instant.now();
new ParkourScoreEvent(pp).call();
// Visual + sound feedback
pp.player.spawnParticle(Particle.TOTEM, ring.center, 40, 0.3, 0.3, 0.3, 0.15);
pp.player.playSound(ring.center, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, centered ? 1.8f : 1.2f);
// Update state score so scoreboard works
state.score = score;
state.totalScore++;
}
private void onRingMissed(ParkourPlayer pp, ElytraRing ring) {
// Flash red to indicate miss — no score
pp.player.spawnParticle(Particle.REDSTONE,
ring.center, 20, 0.5, 0.5, 0.5, 0,
new Particle.DustOptions(Color.RED, 2.0f));
pp.player.playSound(ring.center, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.5f);
}
// ── Fall / Reset ──────────────────────────────────────────────────────────
private void checkFall() {
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
double y = p.getLocation().getY();
// Touched ground or fell too far below spawn
if (p.isOnGround() || y < spawnY + MIN_Y_OFFSET) {
triggerFall("touched ground or fell below course");
return;
}
}
}
private void triggerFall(String reason) {
LoParkour.log("ElytraGenerator fall: " + reason);
new ParkourFallEvent(player).call();
// Save score to leaderboard if it's a new record
Leaderboard lb = getMode().getLeaderboard();
if (lb != null) {
int record = lb.get(player.getUUID()).score();
if (score > record) {
String time = getFormattedTime();
getPlayers().forEach(p -> lb.put(p.getUUID(),
new Score(p.getName(), time, "0.0", score)));
}
}
// Show fall message
sendFallMessage();
reset(true);
}
private void sendFallMessage() {
Leaderboard lb = getMode().getLeaderboard();
int record = lb != null ? lb.get(player.getUUID()).score() : 0;
for (ParkourPlayer pp : getPlayers()) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
pp.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(score));
pp.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
pp.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record));
if (score > record) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.beat",
Integer.toString(score - record));
} else if (score == record) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.tied", "0");
} else {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.miss",
Integer.toString(record - score));
}
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
}
}
@Override
public void reset(boolean regenerate) {
state.stopped = !regenerate;
// Clear rings from world (remove barrier blocks)
rings.forEach(ElytraRing::remove);
rings.clear();
nextRingIndex = 0;
score = 0;
state.score = 0;
startTime = null;
state.start = null;
tickCounter = 0;
fireworkCooldowns.clear();
if (!regenerate) {
stopTasks();
unregisterListener();
island.destroy();
if (getPlayers().isEmpty()) {
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
}
return;
}
// Teleport back to spawn and restart
for (ParkourPlayer pp : getPlayers()) {
pp.teleport(state.playerSpawn);
pp.player.setGliding(false);
pp.player.setVelocity(new Vector(0, 0, 0));
// Restore fireworks
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
pp.player.getInventory().clear();
pp.player.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
pp.player.getInventory().setItem(0, makeFirework(amount));
pp.player.setHeldItemSlot(0);
}
// Re-generate rings
Vector initialHeading = state.heading.clone().normalize();
Location ringOrigin = state.playerSpawn.clone()
.add(initialHeading.clone().multiply(10)).add(0, 2, 0);
generateRings(ringOrigin, initialHeading, ringLead);
// Brief delay then launch
Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
for (ParkourPlayer pp : getPlayers()) {
pp.player.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
pp.player.setGliding(true);
}
}, 10L);
}
// ── Ring rendering ────────────────────────────────────────────────────────
private void renderRings() {
if (state.stopped) return;
Location playerLoc = player.getLocation();
for (int i = nextRingIndex; i < Math.min(nextRingIndex + ringLead, rings.size()); i++) {
ElytraRing ring = rings.get(i);
double dist = ring.center.distance(playerLoc);
if (dist > 80) continue; // Don't render very far rings
// Colour gradient: next ring = gold, further = white, collected = skip
Color color = (i == nextRingIndex) ? Color.YELLOW
: (i == nextRingIndex + 1) ? Color.ORANGE
: Color.WHITE;
renderRing(ring, color);
}
}
private void renderRing(ElytraRing ring, Color color) {
World world = ring.center.getWorld();
if (world == null) return;
// Two orthogonal vectors in the ring's plane
Vector normal = ring.normal.clone().normalize();
Vector up = findPerpendicular(normal);
Vector right = normal.clone().crossProduct(up).normalize();
double r = ring.radius;
Particle.DustOptions dust = new Particle.DustOptions(color, 1.5f);
for (int i = 0; i < RING_POINTS; i++) {
double angle = 2 * Math.PI * i / RING_POINTS;
double x = r * Math.cos(angle);
double y = r * Math.sin(angle);
Location p = ring.center.clone()
.add(right.clone().multiply(x))
.add(up.clone().multiply(y));
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, dust);
}
// Inner ring (smaller, brighter) so it's visible
Particle.DustOptions innerDust = new Particle.DustOptions(Color.WHITE, 0.8f);
double innerR = r * 0.4;
for (int i = 0; i < RING_POINTS / 2; i++) {
double angle = 2 * Math.PI * i / (RING_POINTS / 2);
double x = innerR * Math.cos(angle);
double y = innerR * Math.sin(angle);
Location p = ring.center.clone()
.add(right.clone().multiply(x))
.add(up.clone().multiply(y));
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, innerDust);
}
}
// ── Firework event — cancel if on cooldown ────────────────────────────────
@EventHandler(priority = EventPriority.HIGH)
public void onFireworkUse(PlayerInteractEvent event) {
Player p = event.getPlayer();
ParkourPlayer pp = ParkourPlayer.getPlayer(p);
if (pp == null || !(pp.session.generator instanceof ElytraGenerator gen)) return;
if (gen != this) return;
// Only care about firework rockets
org.bukkit.inventory.ItemStack item = event.getItem();
if (item == null || item.getType() != Material.FIREWORK_ROCKET) return;
boolean isRightClick = event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK;
if (!isRightClick) return;
if (!p.isGliding()) return;
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
UUID uuid = p.getUniqueId();
Long lastUse = fireworkCooldowns.get(uuid);
if (lastUse != null && now - lastUse < cooldownMs) {
event.setCancelled(true);
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
p.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
"§c⏱ Firework cooldown: §f" + remaining + "s"));
return;
}
fireworkCooldowns.put(uuid, now);
// Spawn a boost firework at player location instead of vanilla behaviour
event.setCancelled(true);
spawnBoostFirework(p);
}
/**
* Spawns a visual-only firework that gives the player a velocity boost.
* We do it manually so we control the power and remove the damage.
*/
private void spawnBoostFirework(Player p) {
double power = Config.CONFIG.getDouble("modes.elytra.boost-power");
Vector dir = p.getLocation().getDirection().normalize();
// Remove one firework from inventory
ItemStack held = p.getInventory().getItemInMainHand();
if (held.getType() == Material.FIREWORK_ROCKET) {
if (held.getAmount() > 1) {
held.setAmount(held.getAmount() - 1);
} else {
p.getInventory().setItemInMainHand(null);
}
}
// Apply boost velocity
Vector boost = dir.multiply(power);
p.setVelocity(p.getVelocity().add(boost));
p.setGliding(true);
// Spawn visual firework that instantly explodes (no damage)
Firework fw = p.getWorld().spawn(p.getLocation(), Firework.class);
FireworkMeta meta = fw.getFireworkMeta();
meta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.STAR)
.withColor(Color.AQUA, Color.WHITE)
.withFade(Color.YELLOW)
.withTrail()
.build());
meta.setPower(0);
fw.setFireworkMeta(meta);
fw.setShotAtAngle(true);
// Detonate immediately (no damage tick)
Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (fw.isValid()) fw.detonate();
}, 1L);
p.playSound(p.getLocation(), Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 0.8f, 1.2f);
}
// ── Action bar ────────────────────────────────────────────────────────────
private void updateActionBar(ParkourPlayer pp) {
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
ElytraRing nextRing = rings.get(nextRingIndex);
double dist = Math.round(nextRing.center.distance(pp.getLocation()) * 10.0) / 10.0;
// Firework cooldown indicator
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
Long lastUse = fireworkCooldowns.get(pp.getUUID());
String cooldownStr;
if (lastUse != null && now - lastUse < cooldownMs) {
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
cooldownStr = "§c⏱ " + remaining + "s";
} else {
cooldownStr = "§a✔ Ready";
}
pp.player.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
"§6⬤ §fRings: §e" + score
+ " §7| §f➤ §b" + dist + "m"
+ " §7| §f🚀 " + cooldownStr));
}
// ── Time helpers ──────────────────────────────────────────────────────────
private String getFormattedTime() {
if (startTime == null) return "00:00:000";
var ms = Instant.now().minusMillis(startTime.toEpochMilli());
try {
return DateTimeFormatter.ofPattern("mm:ss:SSS").withZone(ZoneOffset.UTC).format(ms);
} catch (Exception e) {
return "00:00:000";
}
}
// ── Cleanup ───────────────────────────────────────────────────────────────
private void cleanOldRings() {
// Remove rings more than RING_TRAIL behind the player
int removeUntil = nextRingIndex - RING_TRAIL;
if (removeUntil <= 0) return;
Iterator<ElytraRing> it = rings.iterator();
int idx = 0;
while (it.hasNext()) {
ElytraRing ring = it.next();
if (idx < removeUntil) {
ring.remove();
it.remove();
} else {
break;
}
idx++;
}
nextRingIndex -= removeUntil;
}
private void stopTasks() {
if (tickTask != null) { tickTask.cancel(); tickTask = null; }
if (renderTask != null) { renderTask.cancel(); renderTask = null; }
}
private void unregisterListener() {
HandlerList.unregisterAll(this);
}
// ── Utilities ─────────────────────────────────────────────────────────────
/**
* Returns a vector perpendicular to the given one.
* Used to form the ring's local coordinate axes.
*/
private static Vector findPerpendicular(Vector v) {
Vector perp = (Math.abs(v.getX()) < 0.9)
? new Vector(1, 0, 0)
: new Vector(0, 1, 0);
return perp.subtract(v.clone().multiply(perp.dot(v))).normalize();
}
/**
* Rotates a vector by yaw (horizontal) and pitch (vertical) deltas.
*/
private static Vector rotateVector(Vector v, double yawRad, double pitchRad) {
// Rotate around Y axis (yaw)
double cos = Math.cos(yawRad);
double sin = Math.sin(yawRad);
double nx = v.getX() * cos - v.getZ() * sin;
double nz = v.getX() * sin + v.getZ() * cos;
v = new Vector(nx, v.getY(), nz);
// Rotate around the local right axis (pitch)
Vector right = new Vector(v.getZ(), 0, -v.getX()).normalize();
double c = Math.cos(pitchRad);
double s = Math.sin(pitchRad);
double vDotR = v.dot(right);
return new Vector(
v.getX() * c + (right.getY() * v.getZ() - right.getZ() * v.getY()) * s,
v.getY() * c + (right.getZ() * v.getX() - right.getX() * v.getZ()) * s,
v.getZ() * c + (right.getX() * v.getY() - right.getY() * v.getX()) * s
);
}
private static ItemStack makeFirework(int amount) {
ItemStack item = new ItemStack(Material.FIREWORK_ROCKET, amount);
FireworkMeta meta = (FireworkMeta) item.getItemMeta();
if (meta != null) {
meta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.BALL)
.withColor(Color.AQUA)
.build());
meta.setPower(1);
item.setItemMeta(meta);
}
return item;
}
// ── Inner class: ElytraRing ───────────────────────────────────────────────
/**
* Represents one ring in the course.
*
* <p>A ring is defined by its center point and its normal vector (the direction
* of travel through it). The "inside" is a circle of {@code radius} around the center
* on the plane perpendicular to {@code normal}.
*/
public static class ElytraRing {
public final int id;
public final Location center;
/** Direction of travel — the ring faces perpendicular to this. */
public final Vector normal;
public final double radius;
/** Signed distance of the player on the last tick (for plane crossing detection). */
private double lastSignedDist = Double.NaN;
/** Barrier blocks placed for this ring (optional, used if solid rings are enabled). */
private final List<Block> barrierBlocks = new ArrayList<>();
public ElytraRing(int id, Location center, Vector normal, double radius) {
this.id = id;
this.center = center.clone();
this.normal = normal.clone().normalize();
this.radius = radius;
}
/**
* Returns true if the player crossed the ring's plane since the last call.
* Uses the dot product of (playerPos - ringCenter) with the ring's normal.
* Sign change = crossing.
*/
public boolean hasCrossed(Location playerLoc, Vector velocity) {
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
double signedDist = toPlayer.dot(normal);
boolean crossed = false;
if (!Double.isNaN(lastSignedDist)) {
// Crossed if sign flipped (was positive, now negative or vice versa)
crossed = (lastSignedDist > 0) != (signedDist > 0);
}
lastSignedDist = signedDist;
return crossed;
}
/**
* Returns true if the player's position (projected onto the ring's plane)
* is within the ring's radius — i.e. they flew through the hole.
*/
public boolean isInside(Location playerLoc) {
// Project player position onto ring's plane
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
// Remove the component along the normal
Vector projected = toPlayer.clone().subtract(normal.clone().multiply(toPlayer.dot(normal)));
return projected.length() <= radius;
}
/** Remove any barrier blocks this ring placed in the world. */
public void remove() {
barrierBlocks.forEach(b -> b.setType(Material.AIR, false));
barrierBlocks.clear();
}
}
}

View file

@ -0,0 +1,55 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.Item;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Elytra Parkour Mode.
* Players fly through vertical rings generated ahead along a curved aerial path.
* No blocks on the ground — pure flight mechanics.
*/
public class ElytraMode implements Mode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
@Override
@NotNull
public String getName() {
return "elytra";
}
@Override
@Nullable
public Item getItem(String locale) {
return Locales.getItem(locale, "modes.elytra");
}
@Override
@NotNull
public Leaderboard getLeaderboard() {
return leaderboard;
}
@Override
public void create(Player player) {
if (!Config.CONFIG.getBoolean("joining")) {
player.sendMessage("§cJoining is currently disabled.");
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator instanceof ElytraGenerator) {
return;
}
player.closeInventory();
Session.create(session -> new ElytraGenerator(session), null, null, player);
}
}

View file

@ -0,0 +1,150 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.session.Session;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Race Mode — reach the target score as fast as possible.
* Leaderboard sorts by time (fastest wins).
*
* <p>Bugs fixed vs previous version:
* <ul>
* <li>{@code tick()} called {@code super.tick()} AND checked score every tick —
* but super.tick() already calls {@code score()} internally, so the finish
* check ran one tick late causing an off-by-one where state.score could
* briefly exceed targetScore before finishing. Fixed by overriding
* {@link #score()} instead — finish check fires the exact moment target is hit.</li>
* <li>ActionBar was re-sent every tick inside tick() which conflicted with the
* parent's ActionBar updates. Moved to score() so it updates only on change.</li>
* <li>{@code reset(false)} was called via scheduler delay — if the player left the
* server in those 5 seconds it would NPE. Now guarded with null-check.</li>
* <li>{@code getFormattedTime()} is private in parent — accessed via
* {@code getDetailedTime()} which is protected. Fixed method call.</li>
* </ul>
*/
public class RaceMode implements Mode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.TIME);
@Override
@NotNull
public String getName() {
return "race";
}
@Override
@Nullable
public dev.loki.loparkour.util.Item getItem(String locale) {
return Locales.getItem(locale, "modes.race");
}
@Override
@NotNull
public Leaderboard getLeaderboard() {
return leaderboard;
}
@Override
public void create(Player player) {
if (!Config.CONFIG.getBoolean("joining")) {
player.sendMessage("§cJoining is currently disabled.");
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator instanceof RaceGenerator) {
return;
}
player.closeInventory();
Session.create(session -> new RaceGenerator(session), null, null, player);
}
// ── RaceGenerator ─────────────────────────────────────────────────────────
private static class RaceGenerator extends ParkourGenerator {
private final int targetScore;
private boolean raceFinished = false;
public RaceGenerator(@NotNull Session session) {
super(session);
this.targetScore = Config.CONFIG.getInt("modes.race.target-score");
}
/**
* Hook into score() — the exact moment a new point is added.
* This ensures finish detection is frame-perfect with no off-by-one.
*/
@Override
protected void score() {
super.score();
// Update action bar on every new block
updateProgressBar();
if (!raceFinished && state.score >= targetScore) {
finishRace();
}
}
private void updateProgressBar() {
int remaining = Math.max(0, targetScore - state.score);
int filled = Math.min(20, state.score * 20 / targetScore);
String bar = "§a" + "█".repeat(filled) + "§7" + "█".repeat(20 - filled);
getPlayers().forEach(pp ->
pp.player.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
bar + " §e" + state.score + " §7/ §a" + targetScore
+ (remaining > 0 ? " §7(§c" + remaining + " §7left)" : " §6§lDONE!")
)));
}
private void finishRace() {
raceFinished = true;
String time = getDetailedTime();
getPlayers().forEach(pp -> {
pp.player.sendTitle(
"§6§lRACE COMPLETE!",
"§aFinished in §f" + time,
10, 80, 20);
pp.player.playSound(pp.getLocation(),
Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f);
pp.sendTranslated("modes.race.completed",
Integer.toString(targetScore), time);
});
// Save to leaderboard
registerScore(time, "1.0", targetScore);
// Return to lobby after 5 seconds — guarded against null session
dev.lolib.scheduler.Scheduler.get(LoParkour.getPlugin())
.runLater(() -> {
if (!state.stopped) reset(false);
}, 100L);
}
@Override
public void reset(boolean regenerate) {
raceFinished = false;
super.reset(regenerate);
}
@Override
public Mode getMode() {
return Modes.RACE;
}
}
}

View file

@ -0,0 +1,154 @@
package dev.loki.loparkour.mode;
import dev.loki.loparkour.util.Item;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.session.Session;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.Map;
/**
* Speedrun Mode — blocks disappear a short time after the player lands on them.
*
* <p>Bugs fixed vs previous version:
* <ul>
* <li>{@code onBlockTouch()} was never called from anywhere — blocks never disappeared.
* Fixed by hooking into {@link #score()} which fires exactly when the player
* steps onto a new block for the first time.</li>
* <li>Warning task reference was stored in a throwaway local, not in scheduledTasks,
* so it could never be cancelled on reset.</li>
* <li>{@code removalTime / 50} used integer division that truncated sub-second values
* to 0 ticks when blockLifetime < 0.05s. Now uses Math.max(1, ...).</li>
* </ul>
*/
public class SpeedrunMode implements Mode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
@Override
@NotNull
public String getName() {
return "speedrun";
}
@Override
@Nullable
public Item getItem(String locale) {
return Locales.getItem(locale, "play.single.speedrun");
}
@Override
@NotNull
public Leaderboard getLeaderboard() {
return leaderboard;
}
@Override
public void create(Player player) {
if (!Config.CONFIG.getBoolean("joining")) {
player.sendMessage("Joining is currently disabled.");
return;
}
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator instanceof SpeedrunGenerator) {
return;
}
player.closeInventory();
Session.create(session -> new SpeedrunGenerator(session), null, null, player);
}
// ── SpeedrunGenerator ─────────────────────────────────────────────────────
private static class SpeedrunGenerator extends ParkourGenerator {
/** Tracks scheduled tasks per block. Key = block, value = [warningTask, removalTask]. */
private final Map<Block, ScheduledTask[]> scheduledTasks = new HashMap<>();
public SpeedrunGenerator(@NotNull Session session) {
super(session);
}
/**
* Called every time the player scores a point (= lands on a new block).
* We hook here instead of a separate onBlockTouch() because score() is the
* canonical place where "player stepped on block N" is detected.
*/
@Override
protected void score() {
super.score();
// The block the player just landed on is the previous-to-last in history
// (history.size()-1 is the leading block, size()-2 is where player stands)
int histSize = state.history.size();
if (histSize < 2) return;
Block stepped = state.history.get(histSize - 2);
if (stepped.getType() == Material.AIR) return;
if (scheduledTasks.containsKey(stepped)) return; // already scheduled
scheduleBlockRemoval(stepped);
}
private void scheduleBlockRemoval(@NotNull Block block) {
double blockLifetime = Config.CONFIG.getDouble("modes.speedrun.block-lifetime");
double warningTime = Config.CONFIG.getDouble("modes.speedrun.warning-time");
// Convert seconds → ticks (20 ticks/s), minimum 1 tick
long removalTicks = Math.max(1, Math.round(blockLifetime * 20));
long warningTicks = Math.max(1, Math.round(warningTime * 20));
ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (block.getType() != Material.AIR) {
player.player.playSound(block.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
player.player.spawnParticle(Particle.FLAME,
block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
}
}, warningTicks);
ScheduledTask removalTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (block.getType() != Material.AIR) {
block.setType(Material.AIR, false);
}
scheduledTasks.remove(block);
}, removalTicks);
// Store both so we can cancel them on reset
scheduledTasks.put(block, new ScheduledTask[]{warningTask, removalTask});
}
@Override
public void reset(boolean regenerate) {
// Cancel every pending warning and removal task
scheduledTasks.values().forEach(tasks -> {
for (ScheduledTask t : tasks) {
if (t != null) t.cancel();
}
});
scheduledTasks.clear();
super.reset(regenerate);
}
@Override
public Mode getMode() {
return Modes.SPEEDRUN;
}
}
}

View file

@ -0,0 +1,50 @@
package dev.loki.loparkour.storage;
import dev.loki.loparkour.config.Option;
/**
* Manages database schema migrations.
*
* @since 5.0.0
*/
class SQLMigrationManager {
private final SQLQueryExecutor queryExecutor;
public SQLMigrationManager(SQLQueryExecutor queryExecutor) {
this.queryExecutor = queryExecutor;
}
public void initializeDatabase() {
createDatabase();
createOptionsTable();
runMigrations();
}
private void createDatabase() {
queryExecutor.executeStaticUpdate("CREATE DATABASE IF NOT EXISTS `%s`;".formatted(Option.SQL_DB));
queryExecutor.executeStaticUpdate("USE `%s`;".formatted(Option.SQL_DB));
}
private void createOptionsTable() {
queryExecutor.executeStaticUpdate(
("CREATE TABLE IF NOT EXISTS `%soptions` " +
"(`uuid` CHAR(36) NOT NULL, `time` VARCHAR(8), `style` VARCHAR(32), `blockLead` INT, " +
"`useParticles` BOOLEAN, `useDifficulty` BOOLEAN, `useSpecial` BOOLEAN, " +
"`showFallMsg` BOOLEAN, `showScoreboard` BOOLEAN, PRIMARY KEY (`uuid`)) " +
"ENGINE = InnoDB CHARSET = utf8;").formatted(Option.SQL_PREFIX));
}
private void runMigrations() {
String prefix = Option.SQL_PREFIX;
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `time`;".formatted(prefix));
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `selectedTime` INT NOT NULL;".formatted(prefix));
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `collectedRewards` MEDIUMTEXT;".formatted(prefix));
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `locale` VARCHAR(8);".formatted(prefix));
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `schematicDifficulty` DOUBLE;".formatted(prefix));
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `sound` BOOLEAN;".formatted(prefix));
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useDifficulty`;".formatted(prefix));
queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useStructure`;".formatted(prefix));
}
}

View file

@ -0,0 +1,86 @@
package dev.loki.loparkour.storage;
import dev.loki.loparkour.LoParkour;
import java.sql.PreparedStatement;
import java.sql.SQLException;
/**
* Executes SQL queries with error handling.
*
* <p>All public methods accept parameters separately so callers never
* interpolate user data into SQL strings — preventing SQL injection.</p>
*
* @since 5.0.0
*/
class SQLQueryExecutor {
private final SQLConnectionManager connectionManager;
public SQLQueryExecutor(SQLConnectionManager connectionManager) {
this.connectionManager = connectionManager;
}
/**
* Executes a DDL/static update with no user-supplied parameters.
* Only used for CREATE TABLE statements where all values are hardcoded.
*/
public void executeStaticUpdate(String sql) {
executeStaticUpdate(sql, false);
}
/**
* Same as {@link #executeStaticUpdate(String)} but optionally suppresses errors.
* Used for ALTER TABLE migrations that may already have been applied.
*/
public void executeStaticUpdateSuppressed(String sql) {
executeStaticUpdate(sql, true);
}
private void executeStaticUpdate(String sql, boolean suppressErrors) {
connectionManager.validateConnection();
try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) {
if (stmt != null) stmt.executeUpdate();
} catch (SQLException ex) {
if (!suppressErrors) {
LoParkour.getPlugin().getLogger().severe(
"Error while executing static update - " + ex.getMessage());
}
}
}
/**
* Executes a parameterised update. Values are bound via {@link PreparedStatement#setObject}
* — the JDBC driver handles escaping, no interpolation ever touches the SQL string.
*
* @param sql SQL with {@code ?} placeholders
* @param params Values to bind in order
*/
public void executeUpdate(String sql, Object... params) {
connectionManager.validateConnection();
try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) {
if (stmt == null) return;
bindParams(stmt, params);
stmt.executeUpdate();
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error while executing update - " + ex.getMessage());
}
}
/**
* Prepares a statement for callers that need to read a {@link java.sql.ResultSet}.
* Parameters must be bound by the caller before executing.
*/
public PreparedStatement prepareStatement(String sql) {
return connectionManager.prepareStatement(sql);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static void bindParams(PreparedStatement stmt, Object[] params) throws SQLException {
for (int i = 0; i < params.length; i++) {
stmt.setObject(i + 1, params[i]);
}
}
}

View file

@ -0,0 +1,246 @@
package dev.loki.loparkour.storage;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.player.ParkourPlayer;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
import java.sql.*;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* MySQL storage manager.
*
* <p><b>Security:</b> Every query that touches user-supplied data uses
* {@code ?} placeholders bound via {@link PreparedStatement#setObject}.
* No string interpolation of player names, UUIDs, scores, or any other
* external value ever reaches the SQL string itself.</p>
*
* @since 5.0.0
*/
class StorageSQL {
private static final SQLConnectionManager connectionManager = new SQLConnectionManager();
private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager);
private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor);
private static boolean initialized = false;
private static final List<String> pendingTableCreations = new CopyOnWriteArrayList<>();
// ── Connection lifecycle ──────────────────────────────────────────────────
public static boolean isConnected() {
return connectionManager.isConnected();
}
public static void runWhenConnected(Runnable callback) {
connectionManager.runWhenConnected(callback);
}
public static void close() {
connectionManager.close();
}
// ── Initialisation ────────────────────────────────────────────────────────
/**
* Queues creation of the leaderboard table for {@code mode}.
* Table name is constructed from a config-defined prefix (admin-controlled,
* not user input) so formatting here is safe.
*/
public static void init(String mode) {
// Table names are derived from admin config, not player input — safe to format.
String createTableSql = """
CREATE TABLE IF NOT EXISTS `%s`
(
uuid CHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(16),
time VARCHAR(16),
difficulty VARCHAR(3),
score INT
) CHARSET = utf8 ENGINE = InnoDB;
""".formatted(getTableName(mode));
if (!initialized) {
initialized = true;
pendingTableCreations.add(createTableSql);
Bukkit.getScheduler().runTaskAsynchronously(LoParkour.getPlugin(), StorageSQL::connect);
} else {
pendingTableCreations.add(createTableSql);
}
}
// ── Leaderboard ───────────────────────────────────────────────────────────
public static @NotNull Map<UUID, Score> readScores(@NotNull String mode) {
// Table name from admin config — safe to format; no user data here.
String sql = "SELECT * FROM `%s`;".formatted(getTableName(mode));
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
if (stmt == null) return new HashMap<>();
try (ResultSet rs = stmt.executeQuery()) {
Map<UUID, Score> scores = new HashMap<>();
while (rs.next()) {
scores.put(
UUID.fromString(rs.getString("uuid")),
new Score(
rs.getString("name"),
rs.getString("time"),
rs.getString("difficulty"),
rs.getInt("score")));
}
return scores;
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error reading leaderboard for mode %s - %s".formatted(mode, ex.getMessage()));
return new HashMap<>();
}
}
/**
* Writes all scores for a mode.
*
* <p>Uses an INSERT … ON DUPLICATE KEY UPDATE with {@code ?} placeholders.
* Player name, time, difficulty come from the leaderboard record — all bound
* as parameters, never interpolated.</p>
*/
public static void writeScores(@NotNull String mode, @NotNull Map<UUID, Score> scores) {
// Table name is from admin config — safe to format.
String sql = """
INSERT INTO `%s` (uuid, name, time, difficulty, score)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
time = VALUES(time),
difficulty = VALUES(difficulty),
score = VALUES(score);
""".formatted(getTableName(mode));
new HashMap<>(scores).forEach((uuid, score) ->
queryExecutor.executeUpdate(sql,
uuid.toString(),
score.name(),
score.time(),
score.difficulty(),
score.score()));
}
// ── Player settings ───────────────────────────────────────────────────────
/**
* Reads player settings from the options table.
*
* <p>UUID is bound as a parameter — never interpolated into the query string.</p>
*/
public static void readPlayer(@NotNull ParkourPlayer player) {
// Table name from admin config — safe. UUID bound as parameter below.
String sql = "SELECT * FROM `%soptions` WHERE uuid = ?;".formatted(Option.SQL_PREFIX);
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
if (stmt == null) {
player.setSettings(new HashMap<>());
return;
}
// Bind UUID as parameter — no injection possible
stmt.setString(1, player.getUUID().toString());
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) {
player.setSettings(new HashMap<>());
return;
}
Map<String, Object> settings = new HashMap<>();
for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) {
try {
settings.put(key, rs.getObject(key));
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error reading SQL column %s for %s - %s"
.formatted(key, player.getName(), ex.getMessage()));
}
}
player.setSettings(settings);
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error reading player data for %s - %s".formatted(player.getName(), ex.getMessage()));
}
}
/**
* Writes player settings to the options table.
*
* <p>All 12 player-controlled values (style, locale, rewards, etc.) are bound
* as {@code ?} parameters. None of them are interpolated into the SQL string.</p>
*/
public static void writePlayer(@NotNull ParkourPlayer player) {
DecimalFormat df = new DecimalFormat("#.######",
DecimalFormatSymbols.getInstance(Locale.ENGLISH));
double schemDiff = df.format(player.schematicDifficulty) != null
? player.schematicDifficulty : 0.0;
// Table name from admin config — safe to format.
String sql = """
INSERT INTO `%soptions`
(uuid, style, blockLead, useParticles, useSpecial, showFallMsg,
showScoreboard, selectedTime, collectedRewards, locale,
schematicDifficulty, sound)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
style = VALUES(style),
blockLead = VALUES(blockLead),
useParticles = VALUES(useParticles),
useSpecial = VALUES(useSpecial),
showFallMsg = VALUES(showFallMsg),
showScoreboard = VALUES(showScoreboard),
selectedTime = VALUES(selectedTime),
collectedRewards = VALUES(collectedRewards),
locale = VALUES(locale),
schematicDifficulty = VALUES(schematicDifficulty),
sound = VALUES(sound);
""".formatted(Option.SQL_PREFIX);
queryExecutor.executeUpdate(sql,
player.getUUID().toString(), // 1 uuid
player.style, // 2 style
player.blockLead, // 3 blockLead
player.particles, // 4 useParticles
player.useSpecialBlocks, // 5 useSpecial
player.showFallMessage, // 6 showFallMsg
player.showScoreboard, // 7 showScoreboard
player.selectedTime, // 8 selectedTime
String.join(",", player.collectedRewards), // 9 collectedRewards
player.locale, // 10 locale
schemDiff, // 11 schematicDifficulty
player.sound // 12 sound
);
}
// ── Internals ─────────────────────────────────────────────────────────────
/**
* Table name is built from the admin-configured SQL prefix and the internal
* mode name (e.g. "default", "speedrun"). Neither is user-supplied.
*/
private static String getTableName(String mode) {
return "%sleaderboard-%s".formatted(Option.SQL_PREFIX, mode);
}
private static void connect() {
connectionManager.connect();
migrationManager.initializeDatabase();
for (String sql : pendingTableCreations) {
queryExecutor.executeStaticUpdate(sql);
}
pendingTableCreations.clear();
}
}

View file

@ -0,0 +1,524 @@
#===============================================================================#
# #
# 88 88888888ba #
# 88 88 "8b #
# 88 88 ,8P #
# 88 88aaaaaa8P' #
# 88 88""""""' #
# 88 88 #
# 88 88 #
# 88 88 #
# #
#===============================================================================#
#===============================================================================#
# #
# (c) loki 2026 #
# #
#===============================================================================#
# -= Debugging =-
debug: false
# -= Enable joining =-
# Whether players can join the parkour.
# When joining is disabled, the IP instance on this server will only read data from leaderboard databases.
# Set joining to false if you want to create a cross-server leaderboard on this server.
# true = players can join, false = players can't join.
joining: true
# -= Proxy options =-
# Change the proxy settings. Applies to Velocity as well.
# This also applies to servers who only want to run this plugin.
bungeecord:
# -= Proxy enabled =-
# Enabling this will make players who join the server automatically join the parkour.
# You can enable this even without BungeeCord to make this a parkour-only server.
# True = players will automatically join the parkour on join, false = players have to use the menu/parkour command.
enabled: false
# -= Proxy return server =-
# The server the players will be returned to after they leave.
return_server: 'Lobby'
# -= Go back enabled =-
# Whether players should be teleported to a specific location after leaving.
# True = players get teleported to a specific location when they leave, false = they go to their location when they joined.
go-back-enabled: false
# -= Go back location =-
# The place players will go back to when they leave
# Only applies if proxy mode is disabled and the go-back-enabled option is true.
# Syntax: '(x,y,z,worldName)'
go-back: '(0,100,0,world)'
# -= Go back axes =-
# Sets the pitch and yaw of the go-back location.
# The first argument is the pitch, second yaw.
go-back-axes: '0,0'
# -= Storage update interval =-
# The amount of seconds between every disk/database storage update.
# In this update, the in-memory scores will be written to storage.
# A lower number might reduce the chance of losing data on server crashes,
# but will increase the amount of requests to the disk/database.
# (requires a restart, default = 300)
storage-update-interval: 300
# -= MySQL =-
# The options for using MySQL as storage.
sql:
# -= MySQL enabled =-
# Using databases makes you able to sync player data across multiple servers.
# True = data will be stored in a database, false = data will be stored locally.
enabled: false
# -= MySQL settings =-
url: 'localhost'
port: 3306
username: 'efnilite'
password: 'super secret password'
database: 'ip'
# -= MySQL prefix =-
# The prefix given to any table names
# The default is ''. Example: 'ip-' -> this would make the table 'ip-players'
prefix: ''
# -= World options =-
world:
# -= World name =-
# The world in which parkour will take place
name: 'loparkour'
# -= Delete world on reload =-
# Deletes the world on reload/restart of the server.
# This makes sure there are no ghost blocks left when a server crash occurs.
# Disable this if you want to have a custom map.
delete-on-reload: true
# -= World fall back =-
# The world players will be teleported to if they accidentally join the Parkour world without joining parkour
fall-back: 'world'
# -= General options =-
options:
# -= Leads options =-
leads:
# -= Leads amounts =-
# The lead amounts a player can select in their options menu.
# Values also need to be above 1 and below 64.
amount:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
# -= Time options =-
time:
# -= Time format =-
# Choose between:
# 24 - 24 hour clock. (00:00, 12:00, 23:59)
# 12 - 12 hour clock. (12:00 AM, 12:00 PM, 11:59 PM)
format: 24
# -= Score time format =-
# H = hours, m = minutes, s = seconds, S = milliseconds
score-format: "mm:ss:SSS"
# -= Inventory handling =-
# Toggles the inventory handling system.
# True = players' inventories get saved and cleared when they join the parkour and when they leave they get their items back
# False = players keep their inventories when they join the parkour
inventory-handling: true
# -= Inventory saving =-
# Logs inventories of the players that join to the inventories/ folder.
# It is highly recommended to keep this enabled. Server crashes or errors while leaving might cause
# inventories of players to be lost if this is set to false!
# Use /ip recoverinventory to recover inventories of players who joined the parkour and who lost their inventories in a crash, etc.
inventory-saving: true
# -= Alternate inventory saving command =-
# If you don't trust this plugin to save inventories, you can select another plugin to execute a command
# to save players' inventories.
# Example: /inventory save %player%
# This command will be executed by console.
alt-inventory-saving-command: ''
# -= Disables inventory blocks =-
# Disables the ability for a player to open blocks with an inventory.
disable-inventory-blocks: false
# -= Hotbar slots =-
# Configure which hotbar slots (0-8) to use for parkour items
# Items will be placed in these slots in order: play, community, settings, lobby, quit
hotbar-slots:
play: 1
community: 3
settings: 4
lobby: 5
quit: 7
# -= Permissions options =-
# These permissions are used to determine which things the player can change.
# Find them at https://efnilite.dev/projects/ip/wiki
permissions:
# -= Permissions enabled =-
# If permission handling is enabled.
# True = players need a permission to join, view the leaderboard, etc., false = players can do and view anything
# from this plugin besides admin commands.
enabled: false
# -= Per style permissions =-
# Gives every style a permission. The permission is the name of the style.
# Syntax: ip.settings.styles.<style> - example: ip.settings.styles.red
# Useful for servers with ranks.
per-style: false
# -= Focus mode options =-
focus-mode:
# -= Focus mode enabled =-
# If focus mode is enabled players can't use commands while in parkour.
# Note: this might not work on all commands because Minecraft.
enabled: false
# -=
# A whitelist for commands which ARE allowed during focus mode (if focus mode is enabled).
# If you want to disable the default whitelisted commands you can remove every item in the list below.
whitelist:
- 'ip'
- 'parkour'
- 'msg'
- 'r'
- 'w'
- 'whisper'
- 'reply'
- 'message'
# -= Styles options =-
styles:
# -= Styles list =-
# The list of all available styles, which you can add to if you want.
# For changes, you have to restart or reload your server.
list:
red: [red_wool, red_stained_glass, red_concrete, red_terracotta]
blue: [blue_wool, blue_stained_glass, blue_concrete, blue_terracotta]
light blue: [light_blue_wool, light_blue_stained_glass, light_blue_concrete, light_blue_terracotta]
green: [green_wool, green_stained_glass, green_concrete, green_terracotta]
lime: [lime_wool, lime_stained_glass, lime_concrete, lime_terracotta]
cyan: [cyan_wool, cyan_stained_glass, cyan_concrete, cyan_terracotta]
purple: [purple_wool, purple_stained_glass, purple_concrete, purple_terracotta]
yellow: [yellow_wool, yellow_stained_glass, yellow_concrete, yellow_terracotta]
gray: [gray_wool, gray_stained_glass, gray_concrete, gray_terracotta]
end: [end_stone, end_stone_bricks, purpur_block, purpur_pillar]
nether: [netherrack, nether_bricks, red_nether_bricks, chiseled_nether_bricks, nether_wart_block, nether_gold_ore, netherite_block, basalt]
quartz: [smooth_quartz, chiseled_quartz_block, quartz_block, quartz_bricks, quartz_pillar]
ocean: [prismarine, prismarine_bricks, dark_prismarine, sea_lantern]
redstone: [observer, dispenser, redstone_block, piston, sticky_piston, redstone_lamp, target]
overworld: [grass_block, stone, cobblestone, oak_log]
ores: [diamond_ore, gold_ore, iron_ore, coal_ore, nether_gold_ore, lapis_ore, redstone_ore, emerald_ore, nether_quartz_ore]
wood: [oak_planks, spruce_planks, jungle_planks, acacia_planks, birch_planks, dark_oak_planks, oak_log, spruce_log, jungle_log, birch_log, acacia_log, dark_oak_log]
rainbow: [red_wool, blue_wool, orange_wool, magenta_wool, lime_wool, purple_wool, yellow_concrete, orange_concrete, lime_concrete, light_blue_concrete, yellow_stained_glass, white_stained_glass, magenta_stained_glass, light_blue_stained_glass]
glow: [glowstone, jack_o_lantern, beacon, sea_lantern, crying_obsidian, shroomlight]
# -= Jump validation options =-
jump-validation:
# -= Jump validation enabled =-
# Validates generated jumps using Minecraft physics.
# Ensures all jumps are physically possible.
enabled: true
# -= Maximum jump distance =-
# Maximum 3D distance for a jump (Pythagorean theorem).
max-distance: 4.5
# -= Maximum horizontal distance =-
# Maximum horizontal distance (X and Z axes only).
max-horizontal: 4.1
# -= Maximum vertical up =-
# Maximum height a player can jump up.
max-vertical-up: 1.25
# -= Maximum vertical down =-
# Maximum height a player can jump down.
max-vertical-down: 3.0
# -= Jump types options =-
jump-types:
# -= Jump types enabled =-
# Enables special jump types (neo-jumps, fence jumps, etc.).
enabled: false
# -= Jump types list =-
# Configure which jump types are enabled and their spawn chances.
types:
neo-jump:
enabled: true
chance: 0.05
head-hitter:
enabled: true
chance: 0.08
fence-jump:
enabled: true
chance: 0.10
trapdoor-jump:
enabled: true
chance: 0.12
ladder-jump:
enabled: true
chance: 0.07
# -= Memory optimization =-
memory:
# -= Block cleanup distance =-
# Distance in blocks after which old parkour blocks are removed.
# Lower values save memory but might remove blocks too early.
block-cleanup-distance: 20
# -= Cleanup interval =-
# How often (in ticks) to check for blocks to clean up.
# 20 ticks = 1 second
cleanup-interval: 100
# -= Ghost mode options =-
ghost-mode:
# -= Ghost mode enabled =-
# Shows ghosts of top players running alongside you.
enabled: false
# -= Show top =-
# How many top ghosts to show (1-3).
show-top: 3
# -= Ghost transparency =-
# Transparency of ghost entities (0.0 = invisible, 1.0 = fully visible).
transparency: 0.5
# -= Game modes options =-
modes:
# -= Speedrun mode =-
speedrun:
# Block lifetime in seconds before disappearing
block-lifetime: 1.5
# Warning time in seconds before block disappears
warning-time: 0.5
# -= Gravity shift mode =-
gravity-shift:
# Interval in jumps before effect changes
interval: 10
effects:
jump-boost:
enabled: true
amplifier: 2
duration: 30
speed:
enabled: true
amplifier: 1
duration: 30
slowness:
enabled: true
amplifier: 1
duration: 15
levitation:
enabled: true
amplifier: 1
duration: 5
# -= Hardcore mode =-
hardcore:
# Reset all rewards on fall
reset-rewards: true
# -= Elytra mode =-
elytra:
# Enable elytra mode
enabled: true
# --- Ring course settings ---
# Minimum distance (blocks) between consecutive rings
ring-distance-min: 20
# Maximum distance (blocks) between consecutive rings
ring-distance-max: 40
# Ring radius in blocks. Player must fly through the center hole.
ring-size: 5
# How many rings to generate ahead of the player
ring-lead: 8
# Maximum horizontal heading change per ring in degrees (course curvature)
max-heading-change-horizontal: 30
# Maximum vertical heading change per ring in degrees
max-heading-change-vertical: 15
# Maximum Y blocks above spawn point that the course can reach
max-height-above-spawn: 60
# How far from the course (blocks) before a fall is triggered
max-deviation: 25
# --- Firework / boost settings ---
# Firework velocity boost multiplier
boost-power: 1.5
# Cooldown between firework boosts in milliseconds
firework-cooldown-ms: 3000
# Number of fireworks given at session start
starting-fireworks: 64
# -= Race mode =-
race:
# Enable race mode
enabled: true
# Target score to win
target-score: 100
# -= Co-op mode =-
coop:
# Enable co-op mode
enabled: true
# Maximum players in co-op session
max-players: 4
# -= Scoring options =-
scoring:
# -= All points =-
# This will give the player all possible points.
# If a player skips a block jump, it will still give the points for that block.
all-points: false
# -= Rewards use total score =-
# Whether interval rewards should use the total score of a session or not.
# The total score doesn't reset when a player falls.
rewards-use-total-score: false
# -= Particle options =-
particles:
# -= Particle shape =-
# The shape of the particle that will appear when a new block generates.
# Possible options: dot, box, circle
particle-shape: 'box'
# -= Particle type =-
# The type of particle that will appear when a new block generates.
# You can find all types at https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Particle.html
particle-type: 'SPELL_INSTANT'
# -= Sound type =-
# The sound that will play when a new block generates.
# You can find all sounds at https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Sound.html
sound-type: 'BLOCK_NOTE_BLOCK_GUITAR'
# -= Sound pitch =-
# The pitch of the sound that will play when a new block generates.
sound-pitch: 3
# -= Sound volume =-
sound-volume: 2
# -= Default values =-
# This is a list of all options and defaults.
default-values:
main:
enabled: true
quit:
enabled: true
play:
enabled: true
single:
enabled: true
spectator:
enabled: true
community:
enabled: true
leaderboards:
enabled: true
settings:
enabled: true
lang:
enabled: true
default: "en"
chat:
enabled: true
parkour_settings:
item:
enabled: true
items:
styles:
enabled: true
default: "red"
leads:
enabled: true
default: 4
time:
enabled: true
default: 6000
schematics:
enabled: true
default: 0.6
scoreboard:
enabled: true
default: true
fall_message:
enabled: true
default: true
particles:
enabled: true
default: true
sound:
enabled: true
default: true
special_blocks:
enabled: true
default: true
lobby:
enabled: true
visibility:
enabled: true
player_management:
enabled: true

View file

@ -0,0 +1,295 @@
# English (English, en)
# By loki
# Copy this file and rename it to add your own language!
# Materials have to be from: https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Material.html
# Learn more about colouring: https://docs.adventure.kyori.net/minimessage/format.html#format
name: "English"
main:
name: "&#FFFFFFParkour"
play:
name: "&#FFFFFFPlay"
item:
material: "sugar_cane"
name: "&#00D4FF&lPlay"
lore: ""
single:
name: "&#FFFFFFSingle"
item:
material: "ender_pearl"
name: "&#00D4FF&lSingle-player"
lore: ""
default:
material: "barrel"
name: "&#00D4FF&lDefault"
lore: "&#404040The default gamemode."
speedrun:
material: "clock"
name: "&#00D4FF&lSpeedrun"
lore: "&#404040Race against time! Blocks||&#404040disappear after you step on them."
gravity-shift:
material: "feather"
name: "&#00D4FF&lGravity Shift"
lore: "&#404040Gravity changes direction||&#404040as you parkour!"
hardcore:
material: "skeleton_skull"
name: "&#00D4FF&lHardcore"
lore: "&#404040One mistake and you're out!||&#404040No second chances."
spectator:
name: "&#FFFFFFSpectator"
join: "&#00D4FFYou are now a spectator. You can use \"/parkour\" to leave/change your gamemode."
action_bar: "&#00D4FFYou are a spectator &#A0A0A0• &#00D4FFUse \"/parkour\" to leave."
other_join: "&#006400&l+ &r&#A0A0A0%s started spectating you"
other_leave: "&#FF4444&l- &r&#A0A0A0%s stopped spectating you"
item:
material: "glass"
name: "&#00D4FF&lSpectator"
lore: ""
head:
material: ""
name: "&#00D4FF&l%s"
lore: ""
community:
name: "&#FFFFFFCommunity"
item:
material: "oak_boat"
name: "&#00D4FF&lCommunity"
lore: ""
leaderboards:
name: "&#FFFFFFLeaderboards"
item:
material: "gold_nugget"
name: "&#00D4FF&lLeaderboards"
lore: ""
sort:
material: "bookshelf"
name: "&#00D4FF&lSorted by %s"
lore: ""
values: ["score", "time", "difficulty"]
head:
material: ""
name: "&#00D4FF&l#%r - %p"
lore: "&#00D4FFScore: &#A0A0A0%s||&#00D4FFTime: &#A0A0A0%t||&#00D4FFDifficulty: &#A0A0A0%d&#404040/1.0"
settings:
name: "&#FFFFFFSettings"
item:
material: "comparator"
name: "&#00D4FF&lSettings"
lore: ""
lang:
name: "&#FFFFFFLanguage"
item:
material: "writable_book"
name: "&#00D4FF&lLanguage"
lore: "&#404040Currently: &#00D4FF&l%s"
chat:
material: "crossbow"
name: "&#00D4FF&lChat"
lore: "&#404040Currently: &#00D4FF&l%s||&#404040Select who you can chat with"
values: ["lobby only", "players only", "public"]
formats:
lobby: "&#00D4FF(Lobby) &#00D4FF%s &#404040> &#A0A0A0%s"
players: "&#00D4FF(Players) &#00D4FF%s &#404040> &#A0A0A0%s"
parkour_settings:
enabled: "&#00D4FF&l✔"
disabled: "&#FF4444&l✘"
item:
material: "redstone"
name: "&#00D4FF&lParkour Settings"
lore: "&#404040Change the way your parkour appears."
items:
no_change:
material: "barrier"
name: "&#8B0000&lYou cant change this right now!"
lore: "&#A0A0A0You have a score above 0,||&#A0A0A0which means you cant change this setting."
styles:
name: "&#FFFFFFStyles"
style_item:
material: ""
name: "&#FFFFFF&l%s"
lore: "&#00D4FFCategory: &#A0A0A0%s"
item:
material: "crafting_table"
name: "&#00D4FF&lStyle"
lore: "&#00D4FFCurrently: &#A0A0A0%s||&#A0A0A0Choose which blocks will be used."
leads:
material: "feather"
name: "&#00D4FF&lLead"
lore: "&#00D4FFCurrently: &#A0A0A0%s blocks||&#A0A0A0How many blocks will||&#A0A0A0be generated in front of you."
time:
material: "clock"
name: "&#00D4FF&lTime"
lore: "&#00D4FFCurrently: &#A0A0A0%s||&#A0A0A0Your current visual time."
schematics:
material: ""
name: "&#00D4FF&lSchematic difficulty"
lore: "&#00D4FFCurrently: &#A0A0A0%s||&#A0A0A0The difficulty of schematics"
values: ["&#FF4444off", "&#00D4FFeasy", "&#FFFF00medium", "&#00D4FFhard", "&#8B0000very hard"]
scoreboard:
material: ""
name: "Show scoreboard"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled shows the scoreboard."
fall_message:
item:
material: ""
name: "Show fall message"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled shows a message when you fall||&#A0A0A0with extra info."
divider: "&#404040&m-----------------------------------"
score: "&#00D4FF&lScore &r&#FFFFFF%s"
time: "&#00D4FF&lTime &r&#FFFFFF%s"
high_score: "&#00D4FF&lHigh-score &r&#FFFFFF%s"
formats:
miss: "&#A0A0A0You missed your high score by %s points!"
beat: "&#A0A0A0You beat your high score by %s points!"
tied: "&#A0A0A0You tied your high score!"
particles:
material: ""
name: "Show particles"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will show particles."
sound:
material: ""
name: "Play sounds"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will play a sound."
special_blocks:
material: ""
name: "Use special blocks"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, uses special blocks like ice and slabs."
score_difficulty:
material: ""
name: "Score difficulty"
lore: "&#A0A0A0Currently: %s||&#A0A0A0When enabled, the higher score you have,||&#A0A0A0the more difficult it gets."
lobby:
name: "&#FFFFFFLobby"
other_join: '&#006400&l+ &r&#A0A0A0%s joined your lobby'
other_leave: '&#FF4444&l- &r&#A0A0A0%s left your lobby'
item:
material: "iron_ingot"
name: "&#00D4FF&lYour Lobby"
lore: ""
visibility:
material: "firework_rocket"
name: "&#00D4FF&lVisibility"
lore: "&#404040Currently: &#00D4FF&l%s||&#404040Select who can join your lobby."
values: ["private", "invite-only", "public"]
player_management:
name: "&#FFFFFFManage players"
kicked: "&#A0A0A0You've been kicked from your lobby by the lobby owner."
advice: "&#A0A0A0Set your lobby visibility to invite-only or private to avoid people randomly joining."
muted: "&#A0A0A0You've been muted by the lobby owner."
unmuted: "&#A0A0A0You've been unmuted by the lobby owner."
item:
material: "cake"
name: "&#00D4FF&lManage Players"
lore: "&#404040Kick or mute players here."
head:
material: ""
name: "&#00D4FF&l%s"
lore: ""
top: "&#404040Muted in lobby||"
bottom: "&#00D4FFLeft click&#404040 to kick from lobby||&#00D4FFRight click&#404040 to (un)mute in lobby"
scoreboard:
title: "&#00D4FF&lLoParkour"
lines:
- "&#0000FF"
- "&#00D4FFScore &#FFFFFF%score%"
- "&#00D4FFTime &#FFFFFF%time%"
- "&#00D4FFHighscore &#FFFFFF%high_score%"
- "&#FF4444"
- "&#00D4FF&lserver.ip"
other:
no_do: "&#A0A0A0You can't do this right now."
no_teleporting: "&#FFFFFFYou can't enter the parkour world by teleporting!"
customize: "&#A0A0A0Customize your parkour experience by using \"/parkour\""
quit:
material: "barrier"
name: "&#00D4FF&lQuit"
lore: ""
close:
material: "arrow"
name: "&#00D4FF&lClose"
lore: ""
iep:
material: "elytra"
name: "&#00D4FF&lFlying"
lore: "&#A0A0A0Parkour with elytra!"
modes:
elytra:
material: "elytra"
name: "&#00D4FF&lElytra"
lore: "&#A0A0A0Fly through rings!||&#A0A0A0Use fireworks to boost"
start_hint: "&#A0A0A0Fly through the rings! Use fireworks to boost."
race:
material: "golden_boots"
name: "&#FFD700&lRace"
lore: "&#A0A0A0First to reach target score!||&#A0A0A0Compete for fastest time"
completed: "&#FFD700Finished! Score: &#FFFFFF%s &#A0A0Ain &#FFFFFF%s"
coop:
material: "player_head"
name: "&#00FF00&lCo-op"
lore: "&#A0A0A0Team up with friends!||&#A0A0A0Shared score and progress"
player_joined: "&#00FF00%s &#A0A0Ajoined the co-op session!"
stats: "&#A0A0ATeam score: &#FFFFFF%s &#A0A0A| Your blocks: &#FFFFFF%s &#A0A0A(&#FFFFFF%s%%&#A0A0A)"

View file

@ -30,8 +30,6 @@ import org.bstats.charts.SingleLineChart;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.io.File; import java.io.File;
import java.util.ArrayList;
import java.util.List;
/** /**
* Main class of LoParkour * Main class of LoParkour
@ -112,77 +110,8 @@ public final class LoParkour extends LoPlugin {
private void loadSchematics() { private void loadSchematics() {
schematicManager = new LPSchematicManager(); schematicManager = new LPSchematicManager();
// Create default island schematic if it doesn't exist
File schematicsFolder = getInFolder("schematics-new");
File islandFile = new File(schematicsFolder, "island.lpschem");
if (!islandFile.exists()) {
try {
createDefaultIslandSchematic(islandFile);
getLogger().info("Created default island schematic");
} catch (Exception ex) {
getLogger().severe("Failed to create default island schematic: " + ex.getMessage());
ex.printStackTrace();
}
}
schematicManager.loadAll(); schematicManager.loadAll();
} }
private void createDefaultIslandSchematic(File file) throws Exception {
// Create a larger 9x5x9 island for better visibility and safety
List<String> palette = new ArrayList<>();
palette.add("minecraft:air");
palette.add("minecraft:stone");
palette.add("minecraft:diamond_block");
palette.add("minecraft:emerald_block");
int width = 9, height = 5, length = 9;
int[] blocks = new int[width * height * length];
// Fill everything with air first
for (int i = 0; i < blocks.length; i++) {
blocks[i] = 0;
}
// Create a solid stone platform at y=1 (7x7 platform)
for (int z = 1; z < 8; z++) {
for (int x = 1; x < 8; x++) {
int index = x + (z * width) + (1 * width * length);
blocks[index] = 1; // stone
}
}
// Place diamond block (player spawn) at center
int centerX = 4, centerZ = 4;
int diamondIndex = centerX + (centerZ * width) + (1 * width * length);
blocks[diamondIndex] = 2; // diamond_block - player spawns here
// Place emerald block (parkour start) to the right of spawn
int emeraldIndex = (centerX + 1) + (centerZ * width) + (1 * width * length);
blocks[emeraldIndex] = 3; // emerald_block - parkour starts here
dev.loki.loparkour.schematic.lpschem.SchematicMetadata metadata =
new dev.loki.loparkour.schematic.lpschem.SchematicMetadata("island", "LoParkour", 0.0);
metadata.addTag("spawn");
metadata.addTag("island");
dev.loki.loparkour.schematic.lpschem.SchematicDimensions dimensions =
new dev.loki.loparkour.schematic.lpschem.SchematicDimensions(width, height, length);
// Markers point to the blocks where player spawns and parkour begins
dev.loki.loparkour.schematic.lpschem.SchematicMarkers markers =
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers(
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers.Vector3i(centerX, 1, centerZ),
new dev.loki.loparkour.schematic.lpschem.SchematicMarkers.Vector3i(centerX + 1, 1, centerZ)
);
dev.loki.loparkour.schematic.lpschem.LPSchematic schematic =
new dev.loki.loparkour.schematic.lpschem.LPSchematic(metadata, dimensions, palette, blocks, markers);
schematic.save(file);
}
private void registerModes() { private void registerModes() {
Registry.register(new DefaultMode()); Registry.register(new DefaultMode());
@ -256,29 +185,14 @@ public final class LoParkour extends LoPlugin {
ParkourUser.leave(user); ParkourUser.leave(user);
} }
// Save leaderboards for all modes // write all LoParkour gamemodes
if (Modes.DEFAULT != null && Modes.DEFAULT.getLeaderboard() != null) { Modes.DEFAULT.getLeaderboard().write(false);
Modes.DEFAULT.getLeaderboard().write(false);
}
if (Modes.SPEEDRUN != null && Modes.SPEEDRUN.getLeaderboard() != null) {
Modes.SPEEDRUN.getLeaderboard().write(false);
}
if (Modes.ELYTRA != null && Modes.ELYTRA.getLeaderboard() != null) {
Modes.ELYTRA.getLeaderboard().write(false);
}
if (Modes.RACE != null && Modes.RACE.getLeaderboard() != null) {
Modes.RACE.getLeaderboard().write(false);
}
if (Modes.COOP != null && Modes.COOP.getLeaderboard() != null) {
Modes.COOP.getLeaderboard().write(false);
}
Storage.close(); Storage.close();
World.delete(); World.delete();
} catch (Throwable ex) { } catch (Throwable t) {
// Log errors instead of silently ignoring them getLogger().severe("Error during plugin shutdown: " + t.getMessage());
getLogger().severe("Error during plugin disable: " + ex.getMessage()); t.printStackTrace();
ex.printStackTrace();
} }
} }
} }

View file

@ -78,7 +78,7 @@ public class PlayerCommandHandler {
return; return;
} }
Config.reload(false); Config.reload(false);
send(sender, LoParkour.PREFIX + "Reloaded config files."); send(sender, LoParkour.PREFIX + "Reloading config files... (locales are loading asynchronously)");
} }
public void sendHelp(@NotNull CommandSender sender) { public void sendHelp(@NotNull CommandSender sender) {

View file

@ -48,9 +48,10 @@ public class Locales {
public static void init() { public static void init() {
Plugin plugin = LoParkour.getPlugin(); Plugin plugin = LoParkour.getPlugin();
Scheduler.get(plugin).runAsync(() -> { // Create a temporary map to avoid clearing locales before new ones are loaded
locales.clear(); Map<String, FileConfiguration> newLocales = new HashMap<>();
Scheduler.get(plugin).runAsync(() -> {
FileConfiguration embedded = YamlConfiguration.loadConfiguration(new InputStreamReader(plugin.getResource("locales/en.yml"), StandardCharsets.UTF_8)); FileConfiguration embedded = YamlConfiguration.loadConfiguration(new InputStreamReader(plugin.getResource("locales/en.yml"), StandardCharsets.UTF_8));
// get all nodes from the plugin's english resource, aka the most updated version // get all nodes from the plugin's english resource, aka the most updated version
@ -84,11 +85,18 @@ public class Locales {
FileConfiguration config = YamlConfiguration.loadConfiguration(file); FileConfiguration config = YamlConfiguration.loadConfiguration(file);
validate(embedded, config, file); validate(embedded, config, file);
locales.put(locale, config); newLocales.put(locale, config);
}); });
} catch (Exception ex) { } catch (Exception ex) {
LoParkour.getPlugin().getLogger().severe("Error while trying to read locale files - restart/reload your server - " + ex.getMessage()); LoParkour.getPlugin().getLogger().severe("Error while trying to read locale files - restart/reload your server - " + ex.getMessage());
} }
// Only clear and replace locales after all new ones are loaded
Scheduler.get(plugin).run(() -> {
locales.clear();
locales.putAll(newLocales);
LoParkour.log("Locales reloaded successfully (" + locales.size() + " locales)");
});
}); });
} }

View file

@ -33,8 +33,6 @@ import java.util.logging.Level;
*/ */
public class BlockPlacer { public class BlockPlacer {
private static final int MAX_JUMP_VALIDATION_RETRIES = 10;
private final ParkourGenerator g; private final ParkourGenerator g;
public BlockPlacer(ParkourGenerator generator) { public BlockPlacer(ParkourGenerator generator) {
@ -75,29 +73,7 @@ public class BlockPlacer {
g.state.playerSpawn = spawn; g.state.playerSpawn = spawn;
g.state.lastStandingPlayerLocation = spawn; g.state.lastStandingPlayerLocation = spawn;
g.state.blockSpawn = blockSpawn; g.state.blockSpawn = blockSpawn;
g.state.history.add(blockSpawn.getBlock());
// Place first block - always a full block from config
Block firstBlock = blockSpawn.getBlock();
// Get first block material from config, fallback to stone
String firstBlockStr = "stone";
try {
firstBlockStr = Config.GENERATION.getString("advanced.island.parkour.first-block-material");
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().warning("Config key 'advanced.island.parkour.first-block-material' not found, using 'stone'");
}
Material firstBlockMaterial = Material.getMaterial(firstBlockStr.toUpperCase());
if (firstBlockMaterial == null) {
LoParkour.getPlugin().getLogger().warning("Invalid first-block-material: " + firstBlockStr + ", using STONE");
firstBlockMaterial = Material.STONE;
}
// Set first block
firstBlock.setType(firstBlockMaterial);
g.state.history.add(firstBlock);
// Generate remaining blocks
generate(g.profile.get("blockLead").asInt()); generate(g.profile.get("blockLead").asInt());
} }
@ -127,11 +103,28 @@ public class BlockPlacer {
// Restrict jumps after special blocks to prevent impossible jumps // Restrict jumps after special blocks to prevent impossible jumps
Material lastType = g.getLatest().getType(); Material lastType = g.getLatest().getType();
switch (lastType) { BlockData lastBlockData = g.getLatest().getBlockData();
case SMOOTH_QUARTZ_SLAB -> {
height = Math.min(height, 0); // Can't jump up from slab // Check if it's a slab material
distance = Math.min(distance, 3); // Limit distance if (isSlabMaterial(lastType)) {
// Check if it's a bottom slab (height 0.5) or top slab (height 1.0)
if (lastBlockData instanceof org.bukkit.block.data.type.Slab slab) {
if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) {
// Bottom slab: player is 0.5 blocks lower, can't jump as high
height = Math.min(height, -1); // Reduce max height due to lower starting position
distance = Math.min(distance, 3); // Limit distance
} else if (slab.getType() == org.bukkit.block.data.type.Slab.Type.TOP) {
// Top slab: normal restrictions
height = Math.min(height, 0);
distance = Math.min(distance, 3);
}
} else {
// Fallback for unknown slab type
height = Math.min(height, 0);
distance = Math.min(distance, 3);
} }
} else {
switch (lastType) {
case GLASS_PANE -> { case GLASS_PANE -> {
height = Math.min(height, 0); // Can't jump up from pane height = Math.min(height, 0); // Can't jump up from pane
distance = Math.min(distance, 3); // Limit distance distance = Math.min(distance, 3); // Limit distance
@ -156,6 +149,7 @@ public class BlockPlacer {
distance = Math.min(distance, 2); // Very limited distance distance = Math.min(distance, 2); // Very limited distance
} }
} }
}
// Clamp values to valid ranges for JumpOffsetGenerator // Clamp values to valid ranges for JumpOffsetGenerator
height = Math.max(-2, Math.min(1, height)); height = Math.max(-2, Math.min(1, height));
@ -174,10 +168,10 @@ public class BlockPlacer {
Block candidate = current.getLocation().add(offset).getBlock(); Block candidate = current.getLocation().add(offset).getBlock();
// Retry with JumpValidator if jump is impossible (up to MAX_JUMP_VALIDATION_RETRIES attempts) // Retry with JumpValidator if jump is impossible (up to 10 attempts)
JumpValidator validator = new JumpValidator(); JumpValidator validator = new JumpValidator();
int attempts = 0; int attempts = 0;
while (!validator.canJump(current.getLocation(), candidate.getLocation()) && attempts < MAX_JUMP_VALIDATION_RETRIES) { while (!validator.canJump(current.getLocation(), candidate.getLocation()) && attempts < 10) {
// Reduce distance and height to make jump easier // Reduce distance and height to make jump easier
distance = Math.max(1, distance - 1); distance = Math.max(1, distance - 1);
height = Math.max(-1, height - 1); height = Math.max(-1, height - 1);
@ -194,6 +188,15 @@ public class BlockPlacer {
return candidate; return candidate;
} }
/**
* Check if material is a slab type.
*/
private boolean isSlabMaterial(Material material) {
return material == Material.SMOOTH_QUARTZ_SLAB ||
material == Material.STONE_SLAB ||
material.name().endsWith("_SLAB");
}
protected BlockData selectBlockData() { protected BlockData selectBlockData() {
Style style = Registry.getStyle(g.profile.get("style").value()); Style style = Registry.getStyle(g.profile.get("style").value());
@ -243,14 +246,14 @@ public class BlockPlacer {
} }
private JumpType selectJumpType() { private JumpType selectJumpType() {
// Read chances from config // Simple chances: 70% normal, 5% each for special types
Map<JumpType, Double> chances = new HashMap<>(); Map<JumpType, Double> chances = new HashMap<>();
chances.put(JumpType.NORMAL, Config.GENERATION.getDouble("generation.jump-types.normal")); chances.put(JumpType.NORMAL, 70.0);
chances.put(JumpType.NEO_JUMP, Config.GENERATION.getDouble("generation.jump-types.neo")); chances.put(JumpType.NEO_JUMP, 5.0);
chances.put(JumpType.HEAD_HITTER, Config.GENERATION.getDouble("generation.jump-types.head-hitter")); chances.put(JumpType.HEAD_HITTER, 5.0);
chances.put(JumpType.FENCE_JUMP, Config.GENERATION.getDouble("generation.jump-types.fence")); chances.put(JumpType.FENCE_JUMP, 10.0);
chances.put(JumpType.TRAPDOOR_JUMP, Config.GENERATION.getDouble("generation.jump-types.trapdoor")); chances.put(JumpType.TRAPDOOR_JUMP, 5.0);
chances.put(JumpType.LADDER_JUMP, Config.GENERATION.getDouble("generation.jump-types.ladder")); chances.put(JumpType.LADDER_JUMP, 5.0);
return Probs.random(chances); return Probs.random(chances);
} }

View file

@ -1,6 +1,5 @@
package dev.loki.loparkour.generator.jump; package dev.loki.loparkour.generator.jump;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.util.Probs; import dev.loki.loparkour.util.Probs;
import java.util.Map; import java.util.Map;
@ -44,7 +43,8 @@ public class JumpOffsetGenerator {
}; };
default -> { default -> {
// Fallback for unexpected values // Fallback for unexpected values
LoParkour.getPlugin().getLogger().warning("Unexpected jump height " + y + ", using default maxOffset=2"); dev.loki.loparkour.LoParkour.getPlugin().getLogger()
.warning("Unexpected jump height " + y + ", using default maxOffset=2");
yield 2; yield 2;
} }
}; };

View file

@ -32,7 +32,92 @@ public class JumpValidator {
if (from == null || to == null) { if (from == null || to == null) {
return false; return false;
} }
return canJump(from.toVector(), to.toVector());
// Check if jumping from a bottom slab - adjust limits accordingly
double heightAdjustment = getSlabHeightAdjustment(from);
return canJumpWithAdjustment(from.toVector(), to.toVector(), heightAdjustment);
}
/**
* Get height adjustment for slab blocks.
* Returns negative value for bottom slabs (player is lower).
*/
private double getSlabHeightAdjustment(@NotNull Location location) {
try {
org.bukkit.block.Block block = location.getBlock();
if (block != null && isSlabMaterial(block.getType())) {
org.bukkit.block.data.BlockData blockData = block.getBlockData();
if (blockData instanceof org.bukkit.block.data.type.Slab slab) {
if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) {
// Bottom slab: player is 0.5 blocks lower
return -0.5;
}
}
}
} catch (Exception e) {
// In case of mock objects or other issues, fall back to default behavior
}
return 0.0; // No adjustment for full blocks
}
/**
* Check if material is a slab type.
*/
private boolean isSlabMaterial(org.bukkit.Material material) {
return material == org.bukkit.Material.SMOOTH_QUARTZ_SLAB ||
material == org.bukkit.Material.STONE_SLAB ||
material.name().endsWith("_SLAB");
}
private boolean canJumpWithAdjustment(@NotNull Vector from, @NotNull Vector to, double heightAdjustment) {
// Null safety check
if (from == null || to == null) {
return false;
}
double dx = to.getX() - from.getX();
double dy = to.getY() - from.getY();
double dz = to.getZ() - from.getZ();
// Check if same location (no jump)
double horizontalDistance = Math.sqrt(dx * dx + dz * dz);
if (horizontalDistance < 0.01 && Math.abs(dy) < 0.01) {
return false; // Same location
}
// Check horizontal distance limit
if (horizontalDistance > maxHorizontal) {
return false;
}
// Adjust vertical limits based on slab height
double adjustedMaxVerticalUp = maxVerticalUp;
double adjustedMaxVerticalDown = maxVerticalDown;
if (heightAdjustment < 0) {
// Player is lower (bottom slab), reduce upward jump capability
adjustedMaxVerticalUp = maxVerticalUp + heightAdjustment; // Reduce max up jump
adjustedMaxVerticalDown = maxVerticalDown - heightAdjustment; // Increase max down jump
}
// Check vertical limits with adjustment
if (dy > adjustedMaxVerticalUp || dy < -adjustedMaxVerticalDown) {
return false;
}
// For upward jumps, reduce max horizontal distance
// Player can't jump as far when jumping up
if (dy > 0) {
double adjustedMaxHorizontal = maxHorizontal - (dy * 0.5); // Reduce by 0.5 blocks per block up
if (horizontalDistance > adjustedMaxHorizontal) {
return false;
}
}
// Check total distance
double totalDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
return totalDistance <= maxDistance;
} }
// Check if jump is possible: sqrt(dx² + dy² + dz²) <= maxDistance // Check if jump is possible: sqrt(dx² + dy² + dz²) <= maxDistance

View file

@ -13,7 +13,6 @@ import dev.loki.loparkour.ghost.GhostRecorder;
import dev.loki.loparkour.leaderboard.Leaderboard; import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score; import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.Modes; import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.mode.SpeedrunMode;
import dev.loki.loparkour.player.ParkourPlayer; import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator; import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.reward.Rewards; import dev.loki.loparkour.reward.Rewards;
@ -30,10 +29,6 @@ import java.util.Iterator;
*/ */
public class GeneratorLifecycle { public class GeneratorLifecycle {
private static final int FALL_DISTANCE_THRESHOLD = -10;
private static final int SCHEMATIC_CLEANUP_OFFSET = 5;
private static final int SCHEMATIC_PROXIMITY_DISTANCE = 15;
private final ParkourGenerator generator; private final ParkourGenerator generator;
private dev.lolib.scheduler.ScheduledTask cleanupTask; private dev.lolib.scheduler.ScheduledTask cleanupTask;
private GhostRecorder ghostRecorder; private GhostRecorder ghostRecorder;
@ -89,7 +84,7 @@ public class GeneratorLifecycle {
generator.getSpectators().forEach(ParkourSpectator::update); generator.getSpectators().forEach(ParkourSpectator::update);
if (generator.player.getLocation().getWorld() != generator.state.lastStandingPlayerLocation.getWorld()) return; if (generator.player.getLocation().getWorld() != generator.state.lastStandingPlayerLocation.getWorld()) return;
if (generator.player.getLocation().subtract(generator.state.lastStandingPlayerLocation).getY() < FALL_DISTANCE_THRESHOLD) { if (generator.player.getLocation().subtract(generator.state.lastStandingPlayerLocation).getY() < -10) {
fall(); fall();
return; return;
} }
@ -97,11 +92,6 @@ public class GeneratorLifecycle {
Block below = blockBelow(); Block below = blockBelow();
if (below == null) return; if (below == null) return;
// Speedrun mode: trigger block disappearing (only for parkour blocks, not spawn platform)
if (generator.getMode() instanceof SpeedrunMode && generator.state.history.contains(below)) {
((SpeedrunMode.SpeedrunGenerator) generator).onBlockTouch(below);
}
handleSchematicEndBlock(below); handleSchematicEndBlock(below);
// Clean up schematic blocks that are behind the player // Clean up schematic blocks that are behind the player
@ -110,7 +100,7 @@ public class GeneratorLifecycle {
if (currentIdx > 0) { if (currentIdx > 0) {
generator.state.schematicBlocks.removeIf(b -> { generator.state.schematicBlocks.removeIf(b -> {
int blockIdx = generator.state.history.indexOf(b); int blockIdx = generator.state.history.indexOf(b);
if (blockIdx >= 0 && blockIdx < currentIdx - SCHEMATIC_CLEANUP_OFFSET) { if (blockIdx >= 0 && blockIdx < currentIdx - 5) {
b.setType(Material.AIR); b.setType(Material.AIR);
return true; return true;
} }
@ -122,7 +112,7 @@ public class GeneratorLifecycle {
// Reset schematic wait if player is far from schematic blocks // Reset schematic wait if player is far from schematic blocks
if (generator.state.waitForSchematicCompletion && !generator.state.schematicBlocks.isEmpty()) { if (generator.state.waitForSchematicCompletion && !generator.state.schematicBlocks.isEmpty()) {
boolean nearSchematic = generator.state.schematicBlocks.stream() boolean nearSchematic = generator.state.schematicBlocks.stream()
.anyMatch(b -> b.getLocation().distance(generator.player.getLocation()) < SCHEMATIC_PROXIMITY_DISTANCE); .anyMatch(b -> b.getLocation().distance(generator.player.getLocation()) < 15);
if (!nearSchematic) { if (!nearSchematic) {
generator.state.waitForSchematicCompletion = false; generator.state.waitForSchematicCompletion = false;
// Force delete schematic blocks // Force delete schematic blocks
@ -148,7 +138,6 @@ public class GeneratorLifecycle {
generator.state.lastPositionIndexPlayer = idx; generator.state.lastPositionIndexPlayer = idx;
removeTrailBlocks(idx); removeTrailBlocks(idx);
// cleanupDistantBlocks is called by cleanupTask timer, no need to call here
generator.placer.deleteSchematic(); generator.placer.deleteSchematic();
int pts = Config.CONFIG.getBoolean("scoring.all-points") ? delta : 1; int pts = Config.CONFIG.getBoolean("scoring.all-points") ? delta : 1;
@ -173,16 +162,7 @@ public class GeneratorLifecycle {
} }
private @Nullable Block blockBelow() { private @Nullable Block blockBelow() {
Location loc = generator.player.getLocation(); Location loc = generator.player.getLocation().subtract(0, 1, 0);
// Check current block first (for fences, walls, etc with height > 1)
Block current = loc.getBlock();
if (current.getType().isSolid() && !current.getType().isAir()) {
return current;
}
// Check block below
loc = loc.subtract(0, 1, 0);
Block b = loc.getBlock(); Block b = loc.getBlock();
if (b.getType() == Material.AIR) { if (b.getType() == Material.AIR) {
if (loc.subtract(0, 0.5, 0).getBlock().getType() == Material.AIR) return null; if (loc.subtract(0, 0.5, 0).getBlock().getType() == Material.AIR) return null;
@ -244,9 +224,10 @@ public class GeneratorLifecycle {
int score = generator.state.score; int score = generator.state.score;
if (ghostManager.shouldRecordGhost(modeName, score)) { if (ghostManager.shouldRecordGhost(modeName, score)) {
// Pass UUID as well as name — GhostManager names files by UUID
GhostData ghostData = ghostRecorder.stopRecording( GhostData ghostData = ghostRecorder.stopRecording(
generator.player.getUUID(),
generator.player.getName(), generator.player.getName(),
generator.player.getUUID().toString(),
score score
); );
ghostManager.saveGhost(modeName, ghostData); ghostManager.saveGhost(modeName, ghostData);

View file

@ -5,44 +5,56 @@ import org.jetbrains.annotations.NotNull;
import java.io.*; import java.io.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
/**
* Immutable snapshot of a player's run: who ran it, their score, and every recorded frame.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Files were named {@code playerName.ghost} — two players with the same name
* overwrote each other, and a name like {@code ../../config} caused path traversal.
* UUID is now stored and used as the filename instead.</li>
* <li>File format is backwards-compatible: UUID is written first (new field),
* then the existing layout. Old files without a UUID are handled in
* {@link #loadFromFile} by catching the format mismatch and returning null.</li>
* </ul>
*/
public class GhostData { public class GhostData {
/** Binary format version — bump when changing the serialised layout. */
private static final int FORMAT_VERSION = 2;
private final UUID playerUuid;
private final String playerName; private final String playerName;
private final String playerUUID;
private final int score; private final int score;
private final List<GhostFrame> frames; private final List<GhostFrame> frames;
public GhostData(@NotNull String playerName, @NotNull String playerUUID, int score, @NotNull List<GhostFrame> frames) { public GhostData(@NotNull UUID playerUuid, @NotNull String playerName,
int score, @NotNull List<GhostFrame> frames) {
this.playerUuid = playerUuid;
this.playerName = playerName; this.playerName = playerName;
this.playerUUID = playerUUID; this.score = score;
this.score = score; this.frames = new ArrayList<>(frames);
this.frames = new ArrayList<>(frames);
} }
public String getPlayerName() { // ── Getters ───────────────────────────────────────────────────────────────
return playerName;
}
public String getPlayerUUID() { public UUID getPlayerUuid() { return playerUuid; }
return playerUUID; public String getPlayerName() { return playerName; }
} public int getScore() { return score; }
public List<GhostFrame> getFrames() { return frames; }
public int getScore() { // ── Serialisation ─────────────────────────────────────────────────────────
return score;
}
public List<GhostFrame> getFrames() {
return frames;
}
public void saveToFile(@NotNull File file) throws IOException { public void saveToFile(@NotNull File file) throws IOException {
try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { try (DataOutputStream out = new DataOutputStream(
out.writeUTF(playerName); new BufferedOutputStream(new FileOutputStream(file)))) {
out.writeUTF(playerUUID); out.writeInt(FORMAT_VERSION); // version header
out.writeUTF(playerUuid.toString()); // UUID (filename-safe, collision-free)
out.writeUTF(playerName); // display name (for the ghost label)
out.writeInt(score); out.writeInt(score);
out.writeInt(frames.size()); out.writeInt(frames.size());
for (GhostFrame frame : frames) { for (GhostFrame frame : frames) {
out.writeLong(frame.getTimestamp()); out.writeLong(frame.getTimestamp());
out.writeDouble(frame.getX()); out.writeDouble(frame.getX());
@ -54,26 +66,37 @@ public class GhostData {
} }
} }
/**
* Loads a ghost from file.
*
* @return The loaded data, or {@code null} if the file is an old v1 format
* (no version header / UUID) — caller should delete and ignore it.
*/
public static GhostData loadFromFile(@NotNull File file) throws IOException { public static GhostData loadFromFile(@NotNull File file) throws IOException {
try (DataInputStream in = new DataInputStream(new FileInputStream(file))) { try (DataInputStream in = new DataInputStream(
String playerName = in.readUTF(); new BufferedInputStream(new FileInputStream(file)))) {
String playerUUID = in.readUTF();
int score = in.readInt(); int version = in.readInt();
int frameCount = in.readInt(); if (version != FORMAT_VERSION) {
// Old format (v1) started with readUTF for playerName, not an int version.
// Those files are stale — return null so the manager can discard them.
return null;
}
UUID uuid = UUID.fromString(in.readUTF());
String name = in.readUTF();
int score = in.readInt();
int frameCount = in.readInt();
List<GhostFrame> frames = new ArrayList<>(frameCount); List<GhostFrame> frames = new ArrayList<>(frameCount);
for (int i = 0; i < frameCount; i++) { for (int i = 0; i < frameCount; i++) {
long timestamp = in.readLong(); frames.add(new GhostFrame(
double x = in.readDouble(); in.readLong(),
double y = in.readDouble(); in.readDouble(), in.readDouble(), in.readDouble(),
double z = in.readDouble(); in.readFloat(), in.readFloat()));
float yaw = in.readFloat();
float pitch = in.readFloat();
frames.add(new GhostFrame(timestamp, x, y, z, yaw, pitch));
} }
return new GhostData(playerName, playerUUID, score, frames); return new GhostData(uuid, name, score, frames);
} }
} }
} }

View file

@ -1,7 +1,5 @@
package dev.loki.loparkour.ghost; package dev.loki.loparkour.ghost;
import java.util.ArrayList;
import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config; import dev.loki.loparkour.config.Config;
import org.bukkit.Location; import org.bukkit.Location;
@ -13,84 +11,106 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.*; import java.util.*;
/**
* Loads, saves, and spawns ghost runs per mode.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Files were named {@code playerName.ghost} — collisions between players sharing
* a name, and path traversal via names like {@code ../../config}.
* Now named {@code uuid.ghost} — guaranteed unique and filesystem-safe.</li>
* <li>When evicting the lowest ghost, the old file was looked up by player name
* which failed if the name had changed since the run was saved.</li>
* <li>Old v1 files (no UUID) are detected via {@link GhostData#loadFromFile}
* returning null — they are deleted automatically on first load.</li>
* </ul>
*/
public class GhostManager { public class GhostManager {
private static final File GHOST_FOLDER = LoParkour.getInFolder("ghosts");
private static final int MAX_GHOSTS_PER_MODE = 3; private static final int MAX_GHOSTS_PER_MODE = 3;
private final File ghostFolder;
private final Map<String, List<GhostData>> ghostsByMode = new HashMap<>(); private final Map<String, List<GhostData>> ghostsByMode = new HashMap<>();
private final List<GhostPlayer> activeGhosts = new ArrayList<>(); private final List<GhostPlayer> activeGhosts = new ArrayList<>();
public GhostManager() { public GhostManager() {
if (!GHOST_FOLDER.exists()) { this.ghostFolder = LoParkour.getInFolder("ghosts");
GHOST_FOLDER.mkdirs(); if (!ghostFolder.exists()) {
ghostFolder.mkdirs();
} }
} }
// ── Loading ───────────────────────────────────────────────────────────────
public void loadGhosts(@NotNull String mode) { public void loadGhosts(@NotNull String mode) {
File modeFolder = new File(GHOST_FOLDER, mode); File modeFolder = new File(ghostFolder, sanitizeMode(mode));
if (!modeFolder.exists()) { if (!modeFolder.exists()) return;
return;
} File[] files = modeFolder.listFiles((dir, name) -> name.endsWith(".ghost"));
if (files == null) return;
List<GhostData> ghosts = new ArrayList<>(); List<GhostData> ghosts = new ArrayList<>();
File[] files = modeFolder.listFiles((dir, name) -> name.endsWith(".ghost")); for (File file : files) {
try {
if (files != null) { GhostData data = GhostData.loadFromFile(file);
for (File file : files) { if (data == null) {
try { // Old v1 format — delete and skip
GhostData data = GhostData.loadFromFile(file); LoParkour.getPlugin().getLogger().info(
ghosts.add(data); "Deleting outdated ghost file (old format): " + file.getName());
} catch (IOException e) { file.delete();
LoParkour.getPlugin().getLogger().severe("Failed to load ghost: " + file.getName()); continue;
} }
ghosts.add(data);
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe(
"Failed to load ghost file " + file.getName() + ": " + e.getMessage());
} }
} }
ghosts.sort((a, b) -> Integer.compare(b.getScore(), a.getScore())); ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
ghostsByMode.put(mode, ghosts); ghostsByMode.put(mode, ghosts);
} }
// ── Saving ────────────────────────────────────────────────────────────────
public void saveGhost(@NotNull String mode, @NotNull GhostData data) { public void saveGhost(@NotNull String mode, @NotNull GhostData data) {
File modeFolder = new File(GHOST_FOLDER, mode); File modeFolder = new File(ghostFolder, sanitizeMode(mode));
if (!modeFolder.exists()) { if (!modeFolder.exists()) modeFolder.mkdirs();
modeFolder.mkdirs();
}
List<GhostData> ghosts = ghostsByMode.getOrDefault(mode, new ArrayList<>()); List<GhostData> ghosts = new ArrayList<>(ghostsByMode.getOrDefault(mode, new ArrayList<>()));
ghosts.add(data); ghosts.add(data);
ghosts.sort((a, b) -> Integer.compare(b.getScore(), a.getScore())); ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
// Remove old ghosts beyond MAX limit // Evict lowest-score ghosts beyond the cap
while (ghosts.size() > MAX_GHOSTS_PER_MODE) { while (ghosts.size() > MAX_GHOSTS_PER_MODE) {
GhostData removed = ghosts.remove(ghosts.size() - 1); GhostData evicted = ghosts.remove(ghosts.size() - 1);
// Use UUID for filename to avoid collisions and path traversal // File is named by UUID — always finds the right file regardless of name changes
File file = new File(modeFolder, removed.getPlayerUUID() + ".ghost"); File evictedFile = ghostFile(modeFolder, evicted);
file.delete(); if (evictedFile.exists()) evictedFile.delete();
} }
ghostsByMode.put(mode, ghosts); ghostsByMode.put(mode, ghosts);
// Use UUID for filename instead of player name // Save new ghost — named by UUID, not by player name
File file = new File(modeFolder, data.getPlayerUUID() + ".ghost"); File file = ghostFile(modeFolder, data);
try { try {
data.saveToFile(file); data.saveToFile(file);
} catch (IOException e) { } catch (IOException e) {
LoParkour.getPlugin().getLogger().severe("Failed to save ghost: " + file.getName()); LoParkour.getPlugin().getLogger().severe(
"Failed to save ghost for " + data.getPlayerName() + ": " + e.getMessage());
} }
} }
public void spawnGhosts(@NotNull String mode, @NotNull Location startLocation, @NotNull World world) { // ── Spawning ──────────────────────────────────────────────────────────────
if (!Config.CONFIG.getBoolean("ghost-mode.enabled")) {
return; public void spawnGhosts(@NotNull String mode, @NotNull Location startLocation,
} @NotNull World world) {
if (!Config.CONFIG.getBoolean("ghost-mode.enabled")) return;
stopAllGhosts(); stopAllGhosts();
List<GhostData> ghosts = ghostsByMode.get(mode); List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) { if (ghosts == null || ghosts.isEmpty()) return;
return;
}
int showTop = Math.min(Config.CONFIG.getInt("ghost-mode.show-top"), ghosts.size()); int showTop = Math.min(Config.CONFIG.getInt("ghost-mode.show-top"), ghosts.size());
for (int i = 0; i < showTop; i++) { for (int i = 0; i < showTop; i++) {
@ -101,36 +121,42 @@ public class GhostManager {
} }
public void stopAllGhosts() { public void stopAllGhosts() {
for (GhostPlayer ghost : activeGhosts) { activeGhosts.forEach(GhostPlayer::stop);
ghost.stop();
}
activeGhosts.clear(); activeGhosts.clear();
} }
// ── Queries ───────────────────────────────────────────────────────────────
@Nullable @Nullable
public GhostData getTopGhost(@NotNull String mode) { public GhostData getTopGhost(@NotNull String mode) {
List<GhostData> ghosts = ghostsByMode.get(mode); List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) { return (ghosts == null || ghosts.isEmpty()) ? null : ghosts.get(0);
return null;
}
return ghosts.get(0);
} }
public List<GhostData> getTopGhosts(@NotNull String mode, int count) { public List<GhostData> getTopGhosts(@NotNull String mode, int count) {
List<GhostData> ghosts = ghostsByMode.get(mode); List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) { if (ghosts == null || ghosts.isEmpty()) return Collections.emptyList();
return Collections.emptyList(); return Collections.unmodifiableList(ghosts.subList(0, Math.min(count, ghosts.size())));
}
return ghosts.subList(0, Math.min(count, ghosts.size()));
} }
public boolean shouldRecordGhost(@NotNull String mode, int score) { public boolean shouldRecordGhost(@NotNull String mode, int score) {
List<GhostData> ghosts = ghostsByMode.get(mode); List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) { if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) return true;
return true; return score > ghosts.get(ghosts.size() - 1).getScore();
} }
GhostData lowestGhost = ghosts.get(ghosts.size() - 1); // ── Internals ─────────────────────────────────────────────────────────────
return score > lowestGhost.getScore();
/** Returns the ghost file for a run — always UUID-based, never name-based. */
private File ghostFile(File modeFolder, GhostData data) {
return new File(modeFolder, data.getPlayerUuid().toString() + ".ghost");
}
/**
* Strips any path separators from mode names so they can't be used to
* escape the ghosts directory (e.g. mode name "../../etc").
*/
private static String sanitizeMode(String mode) {
return mode.replaceAll("[/\\\\.]", "_");
} }
} }

View file

@ -1,34 +1,40 @@
package dev.loki.loparkour.ghost; package dev.loki.loparkour.ghost;
import dev.lolib.scheduler.Scheduler; import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.LoParkour;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.ArmorStand; import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.EntityType; import org.bukkit.entity.EntityType;
import dev.lolib.scheduler.ScheduledTask;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
/**
* Spawns and animates an ArmorStand that replays a {@link GhostData} run.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Duplicate import of {@code dev.lolib.scheduler.Scheduler} — removed.</li>
* <li>Duplicate field declaration {@code dev.lolib.scheduler.ScheduledTask task}
* shadowed the already-imported type — removed the fully-qualified duplicate.</li>
* </ul>
*/
public class GhostPlayer { public class GhostPlayer {
private final GhostData data; private final GhostData data;
private final World world; private final World world;
private ArmorStand entity; private ArmorStand entity;
private dev.lolib.scheduler.ScheduledTask task; private ScheduledTask task;
private int currentFrame = 0; private int currentFrame = 0;
private long startTime; private long startTime;
public GhostPlayer(@NotNull GhostData data, @NotNull World world) { public GhostPlayer(@NotNull GhostData data, @NotNull World world) {
this.data = data; this.data = data;
this.world = world; this.world = world;
} }
public void spawn(@NotNull Location startLocation) { public void spawn(@NotNull Location startLocation) {
if (data.getFrames().isEmpty()) { if (data.getFrames().isEmpty()) return;
return;
}
entity = (ArmorStand) world.spawnEntity(startLocation, EntityType.ARMOR_STAND); entity = (ArmorStand) world.spawnEntity(startLocation, EntityType.ARMOR_STAND);
entity.setVisible(false); entity.setVisible(false);
@ -37,7 +43,7 @@ public class GhostPlayer {
entity.setCustomName("§7" + data.getPlayerName() + " §8(Ghost)"); entity.setCustomName("§7" + data.getPlayerName() + " §8(Ghost)");
entity.setCustomNameVisible(true); entity.setCustomNameVisible(true);
startTime = System.currentTimeMillis(); startTime = System.currentTimeMillis();
currentFrame = 0; currentFrame = 0;
task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::update, 0, 1); task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::update, 0, 1);
@ -53,42 +59,20 @@ public class GhostPlayer {
while (currentFrame < data.getFrames().size()) { while (currentFrame < data.getFrames().size()) {
GhostFrame frame = data.getFrames().get(currentFrame); GhostFrame frame = data.getFrames().get(currentFrame);
if (frame.getTimestamp() > elapsed) break;
if (frame.getTimestamp() > elapsed) { entity.teleport(frame.toLocation(world));
break;
}
Location loc = frame.toLocation(world);
entity.teleport(loc);
currentFrame++; currentFrame++;
} }
if (currentFrame >= data.getFrames().size()) { if (currentFrame >= data.getFrames().size()) stop();
stop();
}
} }
public void stop() { public void stop() {
if (task != null) { if (task != null) { task.cancel(); task = null; }
task.cancel(); if (entity != null) { entity.remove(); entity = null; }
task = null;
}
if (entity != null) {
entity.remove();
entity = null;
}
} }
public boolean isActive() { public boolean isActive() { return entity != null && entity.isValid(); }
return entity != null && entity.isValid(); public String getPlayerName() { return data.getPlayerName(); }
} public int getScore() { return data.getScore(); }
public String getPlayerName() {
return data.getPlayerName();
}
public int getScore() {
return data.getScore();
}
} }

View file

@ -5,7 +5,17 @@ import org.jetbrains.annotations.NotNull;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
/**
* Records a player's movement as a sequence of {@link GhostFrame}s.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>{@code stopRecording} took only {@code playerName} — now also takes {@code playerUuid}
* so {@link GhostData} can store it and {@link GhostManager} can name files by UUID.</li>
* </ul>
*/
public class GhostRecorder { public class GhostRecorder {
private static final double MIN_MOVEMENT = 0.1; private static final double MIN_MOVEMENT = 0.1;
@ -18,36 +28,34 @@ public class GhostRecorder {
public void startRecording(@NotNull Location startLocation) { public void startRecording(@NotNull Location startLocation) {
frames.clear(); frames.clear();
lastLocation = startLocation.clone(); lastLocation = startLocation.clone();
startTime = System.currentTimeMillis(); startTime = System.currentTimeMillis();
recording = true; recording = true;
frames.add(new GhostFrame(0, startLocation.clone())); frames.add(new GhostFrame(0, startLocation.clone()));
} }
public void recordFrame(@NotNull Location currentLocation) { public void recordFrame(@NotNull Location currentLocation) {
if (!recording) { if (!recording) return;
return; if (lastLocation.distance(currentLocation) < MIN_MOVEMENT) return;
}
if (lastLocation.distance(currentLocation) < MIN_MOVEMENT) {
return;
}
long timestamp = System.currentTimeMillis() - startTime; long timestamp = System.currentTimeMillis() - startTime;
frames.add(new GhostFrame(timestamp, currentLocation.clone())); frames.add(new GhostFrame(timestamp, currentLocation.clone()));
lastLocation = currentLocation.clone(); lastLocation = currentLocation.clone();
} }
public GhostData stopRecording(String playerName, String playerUUID, int score) { /**
* Stops recording and returns the completed ghost data.
*
* @param playerUuid UUID of the player — used as the filename in {@link GhostManager}.
* @param playerName Display name shown on the ghost label in-game.
* @param score The score achieved this run.
*/
public GhostData stopRecording(@NotNull UUID playerUuid,
@NotNull String playerName,
int score) {
recording = false; recording = false;
return new GhostData(playerName, playerUUID, score, frames); return new GhostData(playerUuid, playerName, score, frames);
} }
public boolean isRecording() { public boolean isRecording() { return recording; }
return recording; public int getFrameCount() { return frames.size(); }
}
public int getFrameCount() {
return frames.size();
}
} }

View file

@ -4,8 +4,11 @@ import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales; import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator; import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard; import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.menu.Menus;
import dev.loki.loparkour.player.ParkourPlayer; import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourUser;
import dev.loki.loparkour.session.Session; import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Sound; import org.bukkit.Sound;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@ -16,22 +19,44 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
/** /**
* Coop Mode - Shared score between multiple players * Co-op Mode — multiple players share one session and one score.
*
* <p>Implements {@link MultiMode} so it is excluded from the single-player
* mode selection menu in {@link dev.loki.loparkour.menu.play.SingleMenu}.
* Players join via /parkour coop or through a lobby invite flow.</p>
*
* <p>Problems fixed vs previous version:
* <ul>
* <li>Did NOT implement MultiMode → showed up in single-player menu</li>
* <li>CoopGenerator was a private inner class — inaccessible from join/leave</li>
* <li>sharedScore was a separate field that duplicated state.score</li>
* <li>fall() called super.fall() THEN tried to clear state that was already reset</li>
* <li>playerContributions never cleared between falls correctly</li>
* <li>create(Player) silently created a solo session instead of rejecting</li>
* </ul>
*/ */
public class CoopMode implements Mode { public class CoopMode implements MultiMode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE); private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
// Active coop sessions — used so join() can add to an existing one
// Key = session, tracked via Divider automatically; this is just for lookup
// We look up by existing player's session instead.
@Override @Override
@NotNull @NotNull
public String getName() { public String getName() {
return "coop"; return "coop";
} }
/**
* Returns null so this mode never appears in any menu item list.
* Players join via lobby invite or command only.
*/
@Override @Override
@Nullable @Nullable
public dev.loki.loparkour.util.Item getItem(String locale) { public dev.loki.loparkour.util.Item getItem(String locale) {
return Locales.getItem(locale, "modes.coop"); return null;
} }
@Override @Override
@ -40,92 +65,123 @@ public class CoopMode implements Mode {
return leaderboard; return leaderboard;
} }
/**
* Called when a player tries to start coop solo from a command/menu.
* Opens the lobby menu so they can invite others — does NOT create a session here.
* A session is created only once a second player joins via {@link #join}.
*/
@Override @Override
public void create(Player player) { public void create(Player player) {
if (!Config.CONFIG.getBoolean("joining")) { if (!Config.CONFIG.getBoolean("joining")) {
player.sendMessage(Locales.getString(player, "other.joining_disabled")); player.sendMessage("§cJoining is currently disabled.");
return; return;
} }
// Opening lobby menu lets player invite others — actual session created in join()
ParkourPlayer pp = ParkourPlayer.getPlayer(player); Menus.LOBBY.open(player);
if (pp != null && pp.session.generator != null && pp.session.generator.getMode() instanceof CoopMode) {
return;
}
player.closeInventory();
Session.create(session -> new CoopGenerator(session), null, null, player);
} }
private static class CoopGenerator extends ParkourGenerator { /**
private final Map<UUID, Integer> playerContributions = new HashMap<>(); * Adds a player to an existing coop session, or creates a new session if
private int sharedScore = 0; * the target player is not yet in one.
*/
@Override
public void join(Player joiningPlayer, Session session) {
if (!Config.CONFIG.getBoolean("joining")) {
joiningPlayer.sendMessage("§cJoining is currently disabled.");
return;
}
if (session.generator == null) return;
// Register the joining player into the existing session
ParkourPlayer pp = ParkourUser.register(joiningPlayer, session);
session.addPlayers(pp);
pp.updateGeneratorSettings(session.generator);
// Inform everyone
session.getPlayers().forEach(p ->
p.sendTranslated("modes.coop.player_joined", joiningPlayer.getName()));
}
/**
* Removes a player from a coop session.
* If the last player leaves, the session ends normally.
*/
@Override
public void leave(Player player, Session session) {
ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp == null) return;
ParkourUser.leave(pp);
}
@Override
public int getMaxPlayers() {
return Config.CONFIG.getInt("modes.coop.max-players");
}
// ── CoopGenerator ─────────────────────────────────────────────────────────
/**
* Package-accessible so Session.create() lambda can reference it,
* and so join() can cast generator when needed.
*/
static class CoopGenerator extends ParkourGenerator {
/** Individual block contributions per player UUID. */
private final Map<UUID, Integer> contributions = new HashMap<>();
public CoopGenerator(@NotNull Session session) { public CoopGenerator(@NotNull Session session) {
super(session); super(session);
session.getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0));
// Initialize contributions
for (ParkourPlayer pp : session.getPlayers()) {
playerContributions.put(pp.getUUID(), 0);
pp.player.sendTitle("§a§lCOOP MODE", "§7Work together for a shared score!", 10, 70, 20);
pp.player.sendMessage("§a§lCoop Mode §7activated! Team score is shared.");
}
} }
@Override @Override
protected void score() { protected void score() {
super.score(); super.score(); // increments state.score and totalScore
sharedScore++;
// Track which player was on the block (primary player for single, varies in multi)
// Track individual contributions contributions.merge(player.getUUID(), 1, Integer::sum);
if (!getPlayers().isEmpty()) {
UUID scorer = player.getUUID(); // Milestone every 50 points
playerContributions.put(scorer, playerContributions.getOrDefault(scorer, 0) + 1); if (state.score % 50 == 0) {
} getPlayers().forEach(pp -> {
pp.player.sendTitle(
// Milestone celebrations "§6Team: §e" + state.score,
if (sharedScore % 50 == 0) { "§aKeep it up!",
for (ParkourPlayer pp : getPlayers()) { 5, 40, 10);
pp.player.sendTitle("§6" + sharedScore + " Points!", "§eTeam effort!", 5, 30, 10); pp.player.playSound(pp.getLocation(),
pp.player.playSound(pp.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 0.5f, 1.5f); Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.5f);
} });
} }
} }
@Override /**
public void tick() { * When a new player joins mid-game, add them to contributions map.
super.tick(); */
public void onPlayerJoin(ParkourPlayer pp) {
// Display shared score and contributions in ActionBar contributions.putIfAbsent(pp.getUUID(), 0);
for (ParkourPlayer pp : getPlayers()) {
int myContribution = playerContributions.getOrDefault(pp.getUUID(), 0);
int percentage = sharedScore > 0 ? (myContribution * 100 / sharedScore) : 0;
String message = "§aTeam: §e" + sharedScore + " §7| §bYou: §e" + myContribution + " §7(" + percentage + "%)";
pp.player.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(message)
);
}
} }
@Override @Override
protected void fall() { protected void fall() {
// Show final stats before reset // Show each player their individual contribution before reset
for (ParkourPlayer pp : getPlayers()) { getPlayers().forEach(pp -> {
int contribution = playerContributions.getOrDefault(pp.getUUID(), 0); int contrib = contributions.getOrDefault(pp.getUUID(), 0);
pp.sendTranslated("modes.coop.stats", int pct = state.score > 0 ? (contrib * 100 / state.score) : 0;
Integer.toString(sharedScore), pp.sendTranslated("modes.coop.stats",
Integer.toString(contribution)); Integer.toString(state.score),
} Integer.toString(contrib),
Integer.toString(pct));
super.fall(); });
// Reset coop state super.fall(); // handles reset, leaderboard, etc.
sharedScore = 0; }
playerContributions.clear();
for (ParkourPlayer pp : getPlayers()) { @Override
playerContributions.put(pp.getUUID(), 0); public void reset(boolean regenerate) {
} super.reset(regenerate);
contributions.clear();
getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0));
} }
@Override @Override

View file

@ -1,20 +1,752 @@
package dev.loki.loparkour.mode; package dev.loki.loparkour.mode;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.generator.ParkourGenerator; import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.session.Session; import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.entity.Firework;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.FireworkMeta;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
/** /**
* Generator for Elytra mode parkour * Generator for Elytra mode.
*
* <p>Completely replaces block-based parkour with an aerial ring course:
* <ul>
* <li>Rings are placed ahead along a smooth curved path in the sky</li>
* <li>Each ring is vertical, perpendicular to the flight direction</li>
* <li>Player must fly through the center — detection uses plane-crossing math</li>
* <li>Firework usage is intercepted via event to enforce cooldown properly</li>
* <li>Fall = player touches ground / loses flight / flies too far from course</li>
* </ul>
*/ */
public class ElytraGenerator extends ParkourGenerator { public class ElytraGenerator extends ParkourGenerator implements Listener {
// ── Constants ─────────────────────────────────────────────────────────────
/** How many rings behind the player to keep before deleting. */
private static final int RING_TRAIL = 3;
/** Ticks between particle ring renders (every 2 ticks = 10fps). */
private static final int RENDER_INTERVAL = 2;
/** Minimum Y height — if player drops below this they failed. */
private static final double MIN_Y_OFFSET = -30.0;
// Config-driven values cached at init
private int ringLead;
private double maxDeviation;
/** Particle count per ring point. */
private static final int PARTICLES_PER_POINT = 1;
/** Number of particle points around each ring. */
private static final int RING_POINTS = 36;
// ── State ─────────────────────────────────────────────────────────────────
private final List<ElytraRing> rings = new ArrayList<>();
private int nextRingIndex = 0; // index of the next ring the player hasn't passed
private int tickCounter = 0;
private Instant startTime = null;
private int score = 0;
private ScheduledTask tickTask;
private ScheduledTask renderTask;
/** Firework cooldown per player UUID in ms. */
private final Map<UUID, Long> fireworkCooldowns = new HashMap<>();
/** Starting Y for fall detection. */
private double spawnY;
// ── Constructor ───────────────────────────────────────────────────────────
public ElytraGenerator(@NotNull Session session) { public ElytraGenerator(@NotNull Session session) {
super(session); super(session);
this.ringLead = Config.CONFIG.getInt("modes.elytra.ring-lead");
this.maxDeviation = Config.CONFIG.getDouble("modes.elytra.max-deviation");
} }
// ── Mode identification ───────────────────────────────────────────────────
@Override @Override
public Mode getMode() { public Mode getMode() {
return Modes.ELYTRA; return Modes.ELYTRA;
} }
// ── Lifecycle: called by Session after island.build() ────────────────────
/**
* Called once when the generator is set up.
* We skip the block-based generateFirst and do our own aerial setup.
*/
@Override
public void generateFirst(Location spawn, Location blockSpawn) {
spawnY = spawn.getY();
state.playerSpawn = spawn;
state.lastStandingPlayerLocation = spawn;
state.blockSpawn = blockSpawn;
// Seed the ring path starting just in front of spawn
Vector initialHeading = state.heading.clone().normalize();
Location ringOrigin = spawn.clone().add(initialHeading.clone().multiply(10)).add(0, 2, 0);
generateRings(ringOrigin, initialHeading, ringLead);
// Setup player
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
p.setGameMode(GameMode.ADVENTURE);
p.getInventory().clear();
// Elytra chestplate
p.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
// Starting fireworks
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
p.getInventory().setItem(0, makeFirework(amount));
p.getInventory().setHeldItemSlot(0);
// Launch into glide
p.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
p.setGliding(true);
pp.sendTranslated("modes.elytra.start_hint");
}
// Register firework event listener
Bukkit.getPluginManager().registerEvents(this, LoParkour.getPlugin());
// Start ticks
tickTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 1, 1);
renderTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::renderRings, 0, RENDER_INTERVAL);
}
// ── generateFirst is the entry — skip normal generate() completely ────────
@Override
public void generate() { /* not used in elytra mode */ }
@Override
public void generate(int amount) { /* not used */ }
@Override
public void startTick() { /* tick started in generateFirst */ }
// ── Main tick ─────────────────────────────────────────────────────────────
@Override
public void tick() {
if (state.stopped) {
stopTasks();
return;
}
tickCounter++;
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
// Ensure player stays gliding
if (!p.isGliding() && p.getGameMode() == GameMode.ADVENTURE) {
// Only force-glide if they're not on ground and have elytra
if (!p.isOnGround()) {
p.setGliding(true);
}
}
// Update scoreboard
pp.updateScoreboard(this);
p.setSaturation(20);
// Action bar: score + next ring distance
updateActionBar(pp);
}
// Fall detection
checkFall();
// Ring crossing detection
checkRingCrossings();
}
// ── Ring generation ───────────────────────────────────────────────────────
/**
* Generates {@code count} new rings starting from {@code origin},
* heading in {@code direction}, with smooth curve randomisation.
*/
private void generateRings(Location origin, Vector direction, int count) {
int distMin = Config.CONFIG.getInt("modes.elytra.ring-distance-min");
int distMax = Config.CONFIG.getInt("modes.elytra.ring-distance-max");
double ringSize = Config.CONFIG.getDouble("modes.elytra.ring-size");
double maxYaw = Config.CONFIG.getDouble("modes.elytra.max-heading-change-horizontal");
double maxPitch = Config.CONFIG.getDouble("modes.elytra.max-heading-change-vertical");
double maxHeight = Config.CONFIG.getDouble("modes.elytra.max-height-above-spawn");
ThreadLocalRandom rng = ThreadLocalRandom.current();
Location cursor = origin.clone();
Vector heading = direction.clone().normalize();
// If we already have rings, continue from the last one
if (!rings.isEmpty()) {
ElytraRing last = rings.get(rings.size() - 1);
cursor = last.center.clone();
heading = last.normal.clone();
}
for (int i = 0; i < count; i++) {
// Distance between rings
double dist = distMin + rng.nextDouble() * (distMax - distMin);
// Smooth heading change using config-defined limits
double yawDelta = Math.toRadians(rng.nextDouble(-maxYaw, maxYaw));
double pitchDelta = Math.toRadians(rng.nextDouble(-maxPitch, maxPitch));
heading = rotateVector(heading, yawDelta, pitchDelta);
heading.normalize();
// New ring center
Location center = cursor.clone().add(heading.clone().multiply(dist));
// Keep rings in a reasonable Y band
double targetY = Math.max(spawnY + 5, Math.min(spawnY + maxHeight, center.getY()));
center.setY(targetY);
rings.add(new ElytraRing(rings.size(), center.clone(), heading.clone(), ringSize));
cursor = center;
}
}
// ── Ring crossing detection ───────────────────────────────────────────────
private void checkRingCrossings() {
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
ParkourPlayer pp = player; // primary player
Location loc = pp.getLocation();
ElytraRing nextRing = rings.get(nextRingIndex);
// Check if player crossed the ring's plane
if (nextRing.hasCrossed(loc, pp.player.getVelocity())) {
if (nextRing.isInside(loc)) {
// Perfect pass — score
onRingPassed(pp, nextRing, true);
} else {
// Crossed the plane but missed the ring hole
onRingMissed(pp, nextRing);
}
nextRingIndex++;
// Generate more rings ahead
int ahead = rings.size() - nextRingIndex;
if (ahead < ringLead) {
generateRings(null, null, ringLead - ahead);
}
// Clean up old rings
cleanOldRings();
}
// Check deviation — if player flew too far off course
double deviation = nextRing.center.distance(loc);
if (deviation > maxDeviation) {
triggerFall("flew off course");
}
}
private void onRingPassed(ParkourPlayer pp, ElytraRing ring, boolean centered) {
score++;
if (startTime == null) startTime = Instant.now();
new ParkourScoreEvent(pp).call();
// Visual + sound feedback
pp.player.spawnParticle(Particle.TOTEM, ring.center, 40, 0.3, 0.3, 0.3, 0.15);
pp.player.playSound(ring.center, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, centered ? 1.8f : 1.2f);
// Update state score so scoreboard works
state.score = score;
state.totalScore++;
}
private void onRingMissed(ParkourPlayer pp, ElytraRing ring) {
// Flash red to indicate miss — no score
pp.player.spawnParticle(Particle.REDSTONE,
ring.center, 20, 0.5, 0.5, 0.5, 0,
new Particle.DustOptions(Color.RED, 2.0f));
pp.player.playSound(ring.center, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.5f);
}
// ── Fall / Reset ──────────────────────────────────────────────────────────
private void checkFall() {
for (ParkourPlayer pp : getPlayers()) {
Player p = pp.player;
double y = p.getLocation().getY();
// Touched ground or fell too far below spawn
if (p.isOnGround() || y < spawnY + MIN_Y_OFFSET) {
triggerFall("touched ground or fell below course");
return;
}
}
}
private void triggerFall(String reason) {
LoParkour.log("ElytraGenerator fall: " + reason);
new ParkourFallEvent(player).call();
// Save score to leaderboard if it's a new record
Leaderboard lb = getMode().getLeaderboard();
if (lb != null) {
int record = lb.get(player.getUUID()).score();
if (score > record) {
String time = getFormattedTime();
getPlayers().forEach(p -> lb.put(p.getUUID(),
new Score(p.getName(), time, "0.0", score)));
}
}
// Show fall message
sendFallMessage();
reset(true);
}
private void sendFallMessage() {
Leaderboard lb = getMode().getLeaderboard();
int record = lb != null ? lb.get(player.getUUID()).score() : 0;
for (ParkourPlayer pp : getPlayers()) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
pp.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(score));
pp.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
pp.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record));
if (score > record) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.beat",
Integer.toString(score - record));
} else if (score == record) {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.tied", "0");
} else {
pp.sendTranslated("settings.parkour_settings.items.fall_message.formats.miss",
Integer.toString(record - score));
}
pp.sendTranslated("settings.parkour_settings.items.fall_message.divider");
}
}
@Override
public void reset(boolean regenerate) {
state.stopped = !regenerate;
// Clear rings from world (remove barrier blocks)
rings.forEach(ElytraRing::remove);
rings.clear();
nextRingIndex = 0;
score = 0;
state.score = 0;
startTime = null;
state.start = null;
tickCounter = 0;
fireworkCooldowns.clear();
if (!regenerate) {
stopTasks();
unregisterListener();
island.destroy();
if (getPlayers().isEmpty()) {
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
}
return;
}
// Teleport back to spawn and restart
for (ParkourPlayer pp : getPlayers()) {
pp.teleport(state.playerSpawn);
pp.player.setGliding(false);
pp.player.setVelocity(new Vector(0, 0, 0));
// Restore fireworks
int amount = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
pp.player.getInventory().clear();
pp.player.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
pp.player.getInventory().setItem(0, makeFirework(amount));
pp.player.getInventory().setHeldItemSlot(0);
}
// Re-generate rings
Vector initialHeading = state.heading.clone().normalize();
Location ringOrigin = state.playerSpawn.clone()
.add(initialHeading.clone().multiply(10)).add(0, 2, 0);
generateRings(ringOrigin, initialHeading, ringLead);
// Brief delay then launch
Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
for (ParkourPlayer pp : getPlayers()) {
pp.player.setVelocity(initialHeading.clone().multiply(1.2).setY(0.4));
pp.player.setGliding(true);
}
}, 10L);
}
// ── Ring rendering ────────────────────────────────────────────────────────
private void renderRings() {
if (state.stopped) return;
Location playerLoc = player.getLocation();
for (int i = nextRingIndex; i < Math.min(nextRingIndex + ringLead, rings.size()); i++) {
ElytraRing ring = rings.get(i);
double dist = ring.center.distance(playerLoc);
if (dist > 80) continue; // Don't render very far rings
// Colour gradient: next ring = gold, further = white, collected = skip
Color color = (i == nextRingIndex) ? Color.YELLOW
: (i == nextRingIndex + 1) ? Color.ORANGE
: Color.WHITE;
renderRing(ring, color);
}
}
private void renderRing(ElytraRing ring, Color color) {
World world = ring.center.getWorld();
if (world == null) return;
// Two orthogonal vectors in the ring's plane
Vector normal = ring.normal.clone().normalize();
Vector up = findPerpendicular(normal);
Vector right = normal.clone().crossProduct(up).normalize();
double r = ring.radius;
Particle.DustOptions dust = new Particle.DustOptions(color, 1.5f);
for (int i = 0; i < RING_POINTS; i++) {
double angle = 2 * Math.PI * i / RING_POINTS;
double x = r * Math.cos(angle);
double y = r * Math.sin(angle);
Location p = ring.center.clone()
.add(right.clone().multiply(x))
.add(up.clone().multiply(y));
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, dust);
}
// Inner ring (smaller, brighter) so it's visible
Particle.DustOptions innerDust = new Particle.DustOptions(Color.WHITE, 0.8f);
double innerR = r * 0.4;
for (int i = 0; i < RING_POINTS / 2; i++) {
double angle = 2 * Math.PI * i / (RING_POINTS / 2);
double x = innerR * Math.cos(angle);
double y = innerR * Math.sin(angle);
Location p = ring.center.clone()
.add(right.clone().multiply(x))
.add(up.clone().multiply(y));
world.spawnParticle(Particle.REDSTONE, p, PARTICLES_PER_POINT, 0, 0, 0, 0, innerDust);
}
}
// ── Firework event — cancel if on cooldown ────────────────────────────────
@EventHandler(priority = EventPriority.HIGH)
public void onFireworkUse(PlayerInteractEvent event) {
Player p = event.getPlayer();
ParkourPlayer pp = ParkourPlayer.getPlayer(p);
if (pp == null || !(pp.session.generator instanceof ElytraGenerator gen)) return;
if (gen != this) return;
// Only care about firework rockets
org.bukkit.inventory.ItemStack item = event.getItem();
if (item == null || item.getType() != Material.FIREWORK_ROCKET) return;
boolean isRightClick = event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK;
if (!isRightClick) return;
if (!p.isGliding()) return;
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
UUID uuid = p.getUniqueId();
Long lastUse = fireworkCooldowns.get(uuid);
if (lastUse != null && now - lastUse < cooldownMs) {
event.setCancelled(true);
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
p.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
"§c⏱ Firework cooldown: §f" + remaining + "s"));
return;
}
fireworkCooldowns.put(uuid, now);
// Spawn a boost firework at player location instead of vanilla behaviour
event.setCancelled(true);
spawnBoostFirework(p);
}
/**
* Spawns a visual-only firework that gives the player a velocity boost.
* We do it manually so we control the power and remove the damage.
*/
private void spawnBoostFirework(Player p) {
double power = Config.CONFIG.getDouble("modes.elytra.boost-power");
Vector dir = p.getLocation().getDirection().normalize();
// Remove one firework from inventory
ItemStack held = p.getInventory().getItemInMainHand();
if (held.getType() == Material.FIREWORK_ROCKET) {
if (held.getAmount() > 1) {
held.setAmount(held.getAmount() - 1);
} else {
p.getInventory().setItemInMainHand(null);
}
}
// Apply boost velocity
Vector boost = dir.multiply(power);
p.setVelocity(p.getVelocity().add(boost));
p.setGliding(true);
// Spawn visual firework that instantly explodes (no damage)
Firework fw = p.getWorld().spawn(p.getLocation(), Firework.class);
FireworkMeta meta = fw.getFireworkMeta();
meta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.STAR)
.withColor(Color.AQUA, Color.WHITE)
.withFade(Color.YELLOW)
.withTrail()
.build());
meta.setPower(0);
fw.setFireworkMeta(meta);
fw.setShotAtAngle(true);
// Detonate immediately (no damage tick)
Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (fw.isValid()) fw.detonate();
}, 1L);
p.playSound(p.getLocation(), Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 0.8f, 1.2f);
}
// ── Action bar ────────────────────────────────────────────────────────────
private void updateActionBar(ParkourPlayer pp) {
if (rings.isEmpty() || nextRingIndex >= rings.size()) return;
ElytraRing nextRing = rings.get(nextRingIndex);
double dist = Math.round(nextRing.center.distance(pp.getLocation()) * 10.0) / 10.0;
// Firework cooldown indicator
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
Long lastUse = fireworkCooldowns.get(pp.getUUID());
String cooldownStr;
if (lastUse != null && now - lastUse < cooldownMs) {
long remaining = (cooldownMs - (now - lastUse) + 999) / 1000;
cooldownStr = "§c⏱ " + remaining + "s";
} else {
cooldownStr = "§a✔ Ready";
}
pp.player.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
"§6⬤ §fRings: §e" + score
+ " §7| §f➤ §b" + dist + "m"
+ " §7| §f🚀 " + cooldownStr));
}
// ── Time helpers ──────────────────────────────────────────────────────────
@Override
public String getFormattedTime() {
if (startTime == null) return "00:00:000";
var ms = Instant.now().minusMillis(startTime.toEpochMilli());
try {
return DateTimeFormatter.ofPattern("mm:ss:SSS").withZone(ZoneOffset.UTC).format(ms);
} catch (Exception e) {
return "00:00:000";
}
}
// ── Cleanup ───────────────────────────────────────────────────────────────
private void cleanOldRings() {
// Remove rings more than RING_TRAIL behind the player
int removeUntil = nextRingIndex - RING_TRAIL;
if (removeUntil <= 0) return;
Iterator<ElytraRing> it = rings.iterator();
int idx = 0;
while (it.hasNext()) {
ElytraRing ring = it.next();
if (idx < removeUntil) {
ring.remove();
it.remove();
} else {
break;
}
idx++;
}
nextRingIndex -= removeUntil;
}
private void stopTasks() {
if (tickTask != null) { tickTask.cancel(); tickTask = null; }
if (renderTask != null) { renderTask.cancel(); renderTask = null; }
}
private void unregisterListener() {
HandlerList.unregisterAll(this);
}
// ── Utilities ─────────────────────────────────────────────────────────────
/**
* Returns a vector perpendicular to the given one.
* Used to form the ring's local coordinate axes.
*/
private static Vector findPerpendicular(Vector v) {
Vector perp = (Math.abs(v.getX()) < 0.9)
? new Vector(1, 0, 0)
: new Vector(0, 1, 0);
return perp.subtract(v.clone().multiply(perp.dot(v))).normalize();
}
/**
* Rotates a vector by yaw (horizontal) and pitch (vertical) deltas.
*/
private static Vector rotateVector(Vector v, double yawRad, double pitchRad) {
// Rotate around Y axis (yaw)
double cos = Math.cos(yawRad);
double sin = Math.sin(yawRad);
double nx = v.getX() * cos - v.getZ() * sin;
double nz = v.getX() * sin + v.getZ() * cos;
v = new Vector(nx, v.getY(), nz);
// Rotate around the local right axis (pitch)
Vector right = new Vector(v.getZ(), 0, -v.getX()).normalize();
double c = Math.cos(pitchRad);
double s = Math.sin(pitchRad);
double vDotR = v.dot(right);
return new Vector(
v.getX() * c + (right.getY() * v.getZ() - right.getZ() * v.getY()) * s,
v.getY() * c + (right.getZ() * v.getX() - right.getX() * v.getZ()) * s,
v.getZ() * c + (right.getX() * v.getY() - right.getY() * v.getX()) * s
);
}
private static ItemStack makeFirework(int amount) {
ItemStack item = new ItemStack(Material.FIREWORK_ROCKET, amount);
FireworkMeta meta = (FireworkMeta) item.getItemMeta();
if (meta != null) {
meta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.BALL)
.withColor(Color.AQUA)
.build());
meta.setPower(1);
item.setItemMeta(meta);
}
return item;
}
// ── Inner class: ElytraRing ───────────────────────────────────────────────
/**
* Represents one ring in the course.
*
* <p>A ring is defined by its center point and its normal vector (the direction
* of travel through it). The "inside" is a circle of {@code radius} around the center
* on the plane perpendicular to {@code normal}.
*/
public static class ElytraRing {
public final int id;
public final Location center;
/** Direction of travel — the ring faces perpendicular to this. */
public final Vector normal;
public final double radius;
/** Signed distance of the player on the last tick (for plane crossing detection). */
private double lastSignedDist = Double.NaN;
/** Barrier blocks placed for this ring (optional, used if solid rings are enabled). */
private final List<Block> barrierBlocks = new ArrayList<>();
public ElytraRing(int id, Location center, Vector normal, double radius) {
this.id = id;
this.center = center.clone();
this.normal = normal.clone().normalize();
this.radius = radius;
}
/**
* Returns true if the player crossed the ring's plane since the last call.
* Uses the dot product of (playerPos - ringCenter) with the ring's normal.
* Sign change = crossing.
*/
public boolean hasCrossed(Location playerLoc, Vector velocity) {
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
double signedDist = toPlayer.dot(normal);
boolean crossed = false;
if (!Double.isNaN(lastSignedDist)) {
// Crossed if sign flipped (was positive, now negative or vice versa)
crossed = (lastSignedDist > 0) != (signedDist > 0);
}
lastSignedDist = signedDist;
return crossed;
}
/**
* Returns true if the player's position (projected onto the ring's plane)
* is within the ring's radius — i.e. they flew through the hole.
*/
public boolean isInside(Location playerLoc) {
// Project player position onto ring's plane
Vector toPlayer = playerLoc.toVector().subtract(center.toVector());
// Remove the component along the normal
Vector projected = toPlayer.clone().subtract(normal.clone().multiply(toPlayer.dot(normal)));
return projected.length() <= radius;
}
/** Remove any barrier blocks this ring placed in the world. */
public void remove() {
barrierBlocks.forEach(b -> b.setType(Material.AIR, false));
barrierBlocks.clear();
}
}
} }

View file

@ -1,30 +1,19 @@
package dev.loki.loparkour.mode; package dev.loki.loparkour.mode;
import dev.loki.loparkour.util.Item;
import dev.loki.loparkour.config.Config; import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales; import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.ghost.GhostManager;
import dev.loki.loparkour.ghost.GhostRecorder;
import dev.loki.loparkour.leaderboard.Leaderboard; import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.player.ParkourPlayer; import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.session.Session; import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.util.Item;
import org.bukkit.*;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.*;
/** /**
* Elytra Parkour Mode - Flying parkour with rings and boost * Elytra Parkour Mode.
* Players fly through vertical rings generated ahead along a curved aerial path.
* No blocks on the ground — pure flight mechanics.
*/ */
public class ElytraMode implements Mode { public class ElytraMode implements Mode {
@ -38,7 +27,7 @@ public class ElytraMode implements Mode {
@Override @Override
@Nullable @Nullable
public dev.loki.loparkour.util.Item getItem(String locale) { public Item getItem(String locale) {
return Locales.getItem(locale, "modes.elytra"); return Locales.getItem(locale, "modes.elytra");
} }
@ -51,181 +40,16 @@ public class ElytraMode implements Mode {
@Override @Override
public void create(Player player) { public void create(Player player) {
if (!Config.CONFIG.getBoolean("joining")) { if (!Config.CONFIG.getBoolean("joining")) {
player.sendMessage(Locales.getString(player, "other.joining_disabled")); player.sendMessage("§cJoining is currently disabled.");
return; return;
} }
ParkourPlayer pp = ParkourPlayer.getPlayer(player); ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator != null && pp.session.generator.getMode() instanceof ElytraMode) { if (pp != null && pp.session.generator instanceof ElytraGenerator) {
return; return;
} }
player.closeInventory(); player.closeInventory();
// Create session with elytra setup
Session.create(session -> new ElytraGenerator(session), null, null, player); Session.create(session -> new ElytraGenerator(session), null, null, player);
} }
private static class ElytraGenerator extends ParkourGenerator {
private final List<RingCheckpoint> rings = new ArrayList<>();
private final Map<UUID, Long> fireworkCooldowns = new HashMap<>();
private final Map<UUID, Integer> ringsCollected = new HashMap<>();
private int tickCounter = 0;
public ElytraGenerator(@NotNull Session session) {
super(session);
// Give elytra and fireworks to all players in session
for (ParkourPlayer parkourPlayer : session.getPlayers()) {
Player p = parkourPlayer.player;
p.setGameMode(GameMode.ADVENTURE);
p.getInventory().setChestplate(new ItemStack(Material.ELYTRA));
int fireworks = Config.CONFIG.getInt("modes.elytra.starting-fireworks");
p.getInventory().addItem(new ItemStack(Material.FIREWORK_ROCKET, fireworks));
ringsCollected.put(p.getUniqueId(), 0);
// Notify player
p.sendTitle("§b§lELYTRA MODE", "§7Fly through rings for bonus points!", 10, 70, 20);
p.sendMessage("§b§lElytra Mode §7activated! Use fireworks to boost.");
}
}
@Override
public void tick() {
// Don't call super.tick() - we don't want normal block generation
tickCounter++;
// Update spectators
getSpectators().forEach(ParkourSpectator::update);
// Check for fall (much lower threshold for elytra)
if (player.getLocation().getY() < state.lastStandingPlayerLocation.getY() - 50) {
lifecycle.fall();
return;
}
// Spawn rings every 2 seconds
if (tickCounter % 40 == 0) {
spawnNextRing();
}
// Display rings and check for player passing through
Iterator<RingCheckpoint> it = rings.iterator();
while (it.hasNext()) {
RingCheckpoint ring = it.next();
// Display ring particles
displayRing(ring.location);
// Check if any player passed through
for (ParkourPlayer pp : getPlayers()) {
if (ring.collected) continue;
BoundingBox playerBox = pp.player.getBoundingBox();
if (ring.getBoundingBox().overlaps(playerBox)) {
ring.collected = true;
int count = ringsCollected.getOrDefault(pp.getUUID(), 0) + 1;
ringsCollected.put(pp.getUUID(), count);
// Bonus points for passing through ring
lifecycle.score();
lifecycle.score();
lifecycle.score(); // 3 points per ring
pp.player.playSound(pp.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1.5f);
pp.player.spawnParticle(Particle.TOTEM, ring.location, 20, 0.5, 0.5, 0.5, 0.1);
// Update last standing location to ring location
state.lastStandingPlayerLocation = ring.location.clone();
}
}
// Remove old rings
if (ring.location.distance(player.getLocation()) > 100) {
it.remove();
}
}
// Check firework cooldown
checkFireworkCooldown();
// Start timer if not started
if (state.start == null) state.start = Instant.now();
}
private void spawnNextRing() {
Location playerLoc = player.getLocation();
// Spawn ring ahead of player in flight direction
Vector direction = player.getLocation().getDirection();
Location ringLoc = playerLoc.clone().add(direction.multiply(20)).add(0, 5, 0);
// Add some randomness
ringLoc.add((Math.random() - 0.5) * 10, (Math.random() - 0.5) * 5, (Math.random() - 0.5) * 10);
rings.add(new RingCheckpoint(ringLoc));
}
private void displayRing(Location center) {
World world = center.getWorld();
if (world == null) return;
double radius = 2.0;
int points = 30;
for (int i = 0; i < points; i++) {
double angle = 2 * Math.PI * i / points;
double x = center.getX() + radius * Math.cos(angle);
double z = center.getZ() + radius * Math.sin(angle);
Location particleLoc = new Location(world, x, center.getY(), z);
world.spawnParticle(Particle.END_ROD, particleLoc, 1, 0, 0, 0, 0);
}
}
private void checkFireworkCooldown() {
long now = System.currentTimeMillis();
int cooldownMs = Config.CONFIG.getInt("modes.elytra.firework-cooldown-ms");
for (ParkourPlayer pp : getPlayers()) {
UUID uuid = pp.getUUID();
ItemStack mainHand = pp.player.getInventory().getItemInMainHand();
ItemStack offHand = pp.player.getInventory().getItemInOffHand();
boolean usingFirework = (mainHand.getType() == Material.FIREWORK_ROCKET && pp.player.isGliding())
|| (offHand.getType() == Material.FIREWORK_ROCKET && pp.player.isGliding());
if (usingFirework) {
Long lastUse = fireworkCooldowns.get(uuid);
if (lastUse != null && now - lastUse < cooldownMs) {
long remaining = (cooldownMs - (now - lastUse)) / 1000;
pp.player.spigot().sendMessage(net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText("§cFirework cooldown: " + remaining + "s"));
continue;
}
fireworkCooldowns.put(uuid, now);
}
}
}
@Override
public Mode getMode() {
return Modes.ELYTRA;
}
private static class RingCheckpoint {
final Location location;
boolean collected = false;
RingCheckpoint(Location location) {
this.location = location;
}
BoundingBox getBoundingBox() {
return BoundingBox.of(location, 2.5, 2.5, 2.5);
}
}
}
} }

View file

@ -1,5 +1,6 @@
package dev.loki.loparkour.mode; package dev.loki.loparkour.mode;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config; import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales; import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator; import dev.loki.loparkour.generator.ParkourGenerator;
@ -12,7 +13,23 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
/** /**
* Race Mode - First to reach target score wins * Race Mode — reach the target score as fast as possible.
* Leaderboard sorts by time (fastest wins).
*
* <p>Bugs fixed vs previous version:
* <ul>
* <li>{@code tick()} called {@code super.tick()} AND checked score every tick —
* but super.tick() already calls {@code score()} internally, so the finish
* check ran one tick late causing an off-by-one where state.score could
* briefly exceed targetScore before finishing. Fixed by overriding
* {@link #score()} instead — finish check fires the exact moment target is hit.</li>
* <li>ActionBar was re-sent every tick inside tick() which conflicted with the
* parent's ActionBar updates. Moved to score() so it updates only on change.</li>
* <li>{@code reset(false)} was called via scheduler delay — if the player left the
* server in those 5 seconds it would NPE. Now guarded with null-check.</li>
* <li>{@code getFormattedTime()} is private in parent — accessed via
* {@code getDetailedTime()} which is protected. Fixed method call.</li>
* </ul>
*/ */
public class RaceMode implements Mode { public class RaceMode implements Mode {
@ -39,12 +56,12 @@ public class RaceMode implements Mode {
@Override @Override
public void create(Player player) { public void create(Player player) {
if (!Config.CONFIG.getBoolean("joining")) { if (!Config.CONFIG.getBoolean("joining")) {
player.sendMessage(Locales.getString(player, "other.joining_disabled")); player.sendMessage("§cJoining is currently disabled.");
return; return;
} }
ParkourPlayer pp = ParkourPlayer.getPlayer(player); ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator != null && pp.session.generator.getMode() instanceof RaceMode) { if (pp != null && pp.session.generator instanceof RaceGenerator) {
return; return;
} }
@ -52,58 +69,77 @@ public class RaceMode implements Mode {
Session.create(session -> new RaceGenerator(session), null, null, player); Session.create(session -> new RaceGenerator(session), null, null, player);
} }
// ── RaceGenerator ─────────────────────────────────────────────────────────
private static class RaceGenerator extends ParkourGenerator { private static class RaceGenerator extends ParkourGenerator {
private final int targetScore; private final int targetScore;
private boolean raceFinished = false; private boolean raceFinished = false;
public RaceGenerator(@NotNull Session session) { public RaceGenerator(@NotNull Session session) {
super(session); super(session);
this.targetScore = Config.CONFIG.getInt("modes.race.target-score"); this.targetScore = Config.CONFIG.getInt("modes.race.target-score");
}
// Notify players
for (ParkourPlayer pp : session.getPlayers()) { /**
pp.player.sendTitle("§6§lRACE MODE", "§7First to " + targetScore + " points wins!", 10, 70, 20); * Hook into score() — the exact moment a new point is added.
pp.player.sendMessage("§6§lRace Mode §7activated! Target: §e" + targetScore + " §7points."); * This ensures finish detection is frame-perfect with no off-by-one.
*/
@Override
protected void score() {
super.score();
// Update action bar on every new block
updateProgressBar();
if (!raceFinished && state.score >= targetScore) {
finishRace();
} }
} }
@Override private void updateProgressBar() {
public void tick() { int remaining = Math.max(0, targetScore - state.score);
super.tick(); int filled = Math.min(20, state.score * 20 / targetScore);
String bar = "§a" + "█".repeat(filled) + "§7" + "█".repeat(20 - filled);
if (raceFinished) return;
getPlayers().forEach(pp ->
// Check if target reached
if (state.score >= targetScore) {
finishRace();
}
// Display progress in ActionBar
for (ParkourPlayer pp : getPlayers()) {
int remaining = targetScore - state.score;
String progress = "§e" + state.score + " §7/ §a" + targetScore + " §7(§c" + remaining + " §7left)";
pp.player.spigot().sendMessage( pp.player.spigot().sendMessage(
net.md_5.bungee.api.ChatMessageType.ACTION_BAR, net.md_5.bungee.api.ChatMessageType.ACTION_BAR,
net.md_5.bungee.api.chat.TextComponent.fromLegacyText(progress) net.md_5.bungee.api.chat.TextComponent.fromLegacyText(
); bar + " §e" + state.score + " §7/ §a" + targetScore
} + (remaining > 0 ? " §7(§c" + remaining + " §7left)" : " §6§lDONE!")
)));
} }
private void finishRace() { private void finishRace() {
raceFinished = true; raceFinished = true;
String time = getDetailedTime();
for (ParkourPlayer pp : getPlayers()) {
pp.player.sendTitle("§6§lRACE COMPLETE!", "§aFinished in " + getFormattedTime(), 10, 70, 20); getPlayers().forEach(pp -> {
pp.player.playSound(pp.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f); pp.player.sendTitle(
pp.sendTranslated("modes.race.completed", Integer.toString(targetScore), getFormattedTime()); "§6§lRACE COMPLETE!",
} "§aFinished in §f" + time,
10, 80, 20);
// Register time-based score pp.player.playSound(pp.getLocation(),
registerScore(getDetailedTime(), Double.toString(getDifficultyScore()).substring(0, 3), targetScore); Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f);
pp.sendTranslated("modes.race.completed",
// Stop generator after 5 seconds Integer.toString(targetScore), time);
dev.lolib.scheduler.Scheduler.get(dev.loki.loparkour.LoParkour.getPlugin()) });
.runLater(() -> reset(false), 100);
// Save to leaderboard
registerScore(time, "1.0", targetScore);
// Return to lobby after 5 seconds — guarded against null session
dev.lolib.scheduler.Scheduler.get(LoParkour.getPlugin())
.runLater(() -> {
if (!state.stopped) reset(false);
}, 100L);
}
@Override
public void reset(boolean regenerate) {
raceFinished = false;
super.reset(regenerate);
} }
@Override @Override

View file

@ -1,10 +1,8 @@
package dev.loki.loparkour.mode; package dev.loki.loparkour.mode;
import dev.loki.loparkour.util.Item; import dev.loki.loparkour.util.Item;
import dev.lolib.scheduler.Scheduler; import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask; import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config; import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales; import dev.loki.loparkour.config.Locales;
@ -12,7 +10,6 @@ import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard; import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.player.ParkourPlayer; import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.session.Session; import dev.loki.loparkour.session.Session;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.Particle; import org.bukkit.Particle;
import org.bukkit.Sound; import org.bukkit.Sound;
@ -24,6 +21,20 @@ import org.jetbrains.annotations.Nullable;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
/**
* Speedrun Mode — blocks disappear a short time after the player lands on them.
*
* <p>Bugs fixed vs previous version:
* <ul>
* <li>{@code onBlockTouch()} was never called from anywhere — blocks never disappeared.
* Fixed by hooking into {@link #score()} which fires exactly when the player
* steps onto a new block for the first time.</li>
* <li>Warning task reference was stored in a throwaway local, not in scheduledTasks,
* so it could never be cancelled on reset.</li>
* <li>{@code removalTime / 50} used integer division that truncated sub-second values
* to 0 ticks when blockLifetime < 0.05s. Now uses Math.max(1, ...).</li>
* </ul>
*/
public class SpeedrunMode implements Mode { public class SpeedrunMode implements Mode {
private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE); private final Leaderboard leaderboard = new Leaderboard(getName(), Leaderboard.Sort.SCORE);
@ -36,7 +47,7 @@ public class SpeedrunMode implements Mode {
@Override @Override
@Nullable @Nullable
public dev.loki.loparkour.util.Item getItem(String locale) { public Item getItem(String locale) {
return Locales.getItem(locale, "play.single.speedrun"); return Locales.getItem(locale, "play.single.speedrun");
} }
@ -54,72 +65,84 @@ public class SpeedrunMode implements Mode {
} }
ParkourPlayer pp = ParkourPlayer.getPlayer(player); ParkourPlayer pp = ParkourPlayer.getPlayer(player);
if (pp != null && pp.session.generator != null && pp.session.generator.getMode() instanceof SpeedrunMode) { if (pp != null && pp.session.generator instanceof SpeedrunGenerator) {
return; return;
} }
player.closeInventory(); player.closeInventory();
Session.create(session -> new SpeedrunGenerator(session), null, null, player); Session.create(session -> new SpeedrunGenerator(session), null, null, player);
} }
public static class SpeedrunGenerator extends ParkourGenerator { // ── SpeedrunGenerator ─────────────────────────────────────────────────────
private final Map<Block, Long> blockTimestamps = new HashMap<>();
private final Map<Block, ScheduledTask> scheduledTasks = new HashMap<>(); private static class SpeedrunGenerator extends ParkourGenerator {
/** Tracks scheduled tasks per block. Key = block, value = [warningTask, removalTask]. */
private final Map<Block, ScheduledTask[]> scheduledTasks = new HashMap<>();
public SpeedrunGenerator(@NotNull Session session) { public SpeedrunGenerator(@NotNull Session session) {
super(session); super(session);
// Notify player
for (ParkourPlayer pp : session.getPlayers()) {
pp.player.sendTitle("§c§lSPEEDRUN MODE", "§7Blocks disappear after you step on them!", 10, 70, 20);
pp.player.sendMessage("§c§lSpeedrun Mode §7activated! Don't look back!");
}
} }
public void onBlockTouch(@NotNull Block block) { /**
if (!blockTimestamps.containsKey(block)) { * Called every time the player scores a point (= lands on a new block).
blockTimestamps.put(block, System.currentTimeMillis()); * We hook here instead of a separate onBlockTouch() because score() is the
scheduleBlockRemoval(block); * canonical place where "player stepped on block N" is detected.
} */
@Override
protected void score() {
super.score();
// The block the player just landed on is the previous-to-last in history
// (history.size()-1 is the leading block, size()-2 is where player stands)
int histSize = state.history.size();
if (histSize < 2) return;
Block stepped = state.history.get(histSize - 2);
if (stepped.getType() == Material.AIR) return;
if (scheduledTasks.containsKey(stepped)) return; // already scheduled
scheduleBlockRemoval(stepped);
} }
private void scheduleBlockRemoval(@NotNull Block block) { private void scheduleBlockRemoval(@NotNull Block block) {
double blockLifetime = Config.CONFIG.getDouble("modes.speedrun.block-lifetime"); double blockLifetime = Config.CONFIG.getDouble("modes.speedrun.block-lifetime");
double warningTime = Config.CONFIG.getDouble("modes.speedrun.warning-time"); double warningTime = Config.CONFIG.getDouble("modes.speedrun.warning-time");
long removalTime = (long) (blockLifetime * 1000); // Convert seconds → ticks (20 ticks/s), minimum 1 tick
long warningTimeMs = (long) (warningTime * 1000); long removalTicks = Math.max(1, Math.round(blockLifetime * 20));
long warningTicks = Math.max(1, Math.round(warningTime * 20));
ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> { ScheduledTask warningTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (block.getType() != Material.AIR) { if (block.getType() != Material.AIR) {
player.player.playSound(block.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f); player.player.playSound(block.getLocation(),
player.player.spawnParticle(Particle.FLAME, block.getLocation().add(0.5, 1, 0.5), 10, 0.3, 0.3, 0.3, 0.01); Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, 2.0f);
} player.player.spawnParticle(Particle.FLAME,
}, warningTimeMs / 50); block.getLocation().add(0.5, 1, 0.5), 8, 0.2, 0.2, 0.2, 0.01);
}
}, warningTicks);
ScheduledTask removalTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> { ScheduledTask removalTask = Scheduler.get(LoParkour.getPlugin()).runLater(() -> {
if (block.getType() != Material.AIR) { if (block.getType() != Material.AIR) {
block.setType(Material.AIR); block.setType(Material.AIR, false);
blockTimestamps.remove(block); }
scheduledTasks.remove(block); scheduledTasks.remove(block);
} }, removalTicks);
}, removalTime / 50);
scheduledTasks.put(block, removalTask); // Store both so we can cancel them on reset
scheduledTasks.put(block, new ScheduledTask[]{warningTask, removalTask});
} }
@Override @Override
public void reset(boolean regenerate) { public void reset(boolean regenerate) {
// Cancel all scheduled tasks // Cancel every pending warning and removal task
for (ScheduledTask task : scheduledTasks.values()) { scheduledTasks.values().forEach(tasks -> {
if (task != null) { for (ScheduledTask t : tasks) {
task.cancel(); if (t != null) t.cancel();
} }
} });
scheduledTasks.clear(); scheduledTasks.clear();
blockTimestamps.clear();
super.reset(regenerate); super.reset(regenerate);
} }

View file

@ -22,12 +22,12 @@ class SQLMigrationManager {
} }
private void createDatabase() { private void createDatabase() {
queryExecutor.executeUpdate("CREATE DATABASE IF NOT EXISTS `%s`;".formatted(Option.SQL_DB)); queryExecutor.executeStaticUpdate("CREATE DATABASE IF NOT EXISTS `%s`;".formatted(Option.SQL_DB));
queryExecutor.executeUpdate("USE `%s`;".formatted(Option.SQL_DB)); queryExecutor.executeStaticUpdate("USE `%s`;".formatted(Option.SQL_DB));
} }
private void createOptionsTable() { private void createOptionsTable() {
queryExecutor.executeUpdate( queryExecutor.executeStaticUpdate(
("CREATE TABLE IF NOT EXISTS `%soptions` " + ("CREATE TABLE IF NOT EXISTS `%soptions` " +
"(`uuid` CHAR(36) NOT NULL, `time` VARCHAR(8), `style` VARCHAR(32), `blockLead` INT, " + "(`uuid` CHAR(36) NOT NULL, `time` VARCHAR(8), `style` VARCHAR(32), `blockLead` INT, " +
"`useParticles` BOOLEAN, `useDifficulty` BOOLEAN, `useSpecial` BOOLEAN, " + "`useParticles` BOOLEAN, `useDifficulty` BOOLEAN, `useSpecial` BOOLEAN, " +
@ -38,13 +38,13 @@ class SQLMigrationManager {
private void runMigrations() { private void runMigrations() {
String prefix = Option.SQL_PREFIX; String prefix = Option.SQL_PREFIX;
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `time`;".formatted(prefix)); queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `time`;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `selectedTime` INT NOT NULL;".formatted(prefix)); queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `selectedTime` INT NOT NULL;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `collectedRewards` MEDIUMTEXT;".formatted(prefix)); queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `collectedRewards` MEDIUMTEXT;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `locale` VARCHAR(8);".formatted(prefix)); queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `locale` VARCHAR(8);".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `schematicDifficulty` DOUBLE;".formatted(prefix)); queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `schematicDifficulty` DOUBLE;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `sound` BOOLEAN;".formatted(prefix)); queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` ADD `sound` BOOLEAN;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useDifficulty`;".formatted(prefix)); queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useDifficulty`;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useStructure`;".formatted(prefix)); queryExecutor.executeStaticUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useStructure`;".formatted(prefix));
} }
} }

View file

@ -8,6 +8,9 @@ import java.sql.SQLException;
/** /**
* Executes SQL queries with error handling. * Executes SQL queries with error handling.
* *
* <p>All public methods accept parameters separately so callers never
* interpolate user data into SQL strings — preventing SQL injection.</p>
*
* @since 5.0.0 * @since 5.0.0
*/ */
class SQLQueryExecutor { class SQLQueryExecutor {
@ -18,30 +21,66 @@ class SQLQueryExecutor {
this.connectionManager = connectionManager; this.connectionManager = connectionManager;
} }
public void executeUpdate(String sql) { /**
executeUpdate(sql, false); * Executes a DDL/static update with no user-supplied parameters.
* Only used for CREATE TABLE statements where all values are hardcoded.
*/
public void executeStaticUpdate(String sql) {
executeStaticUpdate(sql, false);
} }
public void executeUpdateSuppressed(String sql) { /**
executeUpdate(sql, true); * Same as {@link #executeStaticUpdate(String)} but optionally suppresses errors.
* Used for ALTER TABLE migrations that may already have been applied.
*/
public void executeStaticUpdateSuppressed(String sql) {
executeStaticUpdate(sql, true);
} }
private void executeUpdate(String sql, boolean suppressErrors) { private void executeStaticUpdate(String sql, boolean suppressErrors) {
connectionManager.validateConnection(); connectionManager.validateConnection();
try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) { try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) {
if (stmt != null) { if (stmt != null) stmt.executeUpdate();
stmt.executeUpdate();
}
} catch (SQLException ex) { } catch (SQLException ex) {
if (!suppressErrors) { if (!suppressErrors) {
LoParkour.getPlugin().getLogger().severe( LoParkour.getPlugin().getLogger().severe(
"Error while sending update: %s - %s".formatted(sql, ex.getMessage())); "Error while executing static update - " + ex.getMessage());
} }
} }
} }
/**
* Executes a parameterised update. Values are bound via {@link PreparedStatement#setObject}
* — the JDBC driver handles escaping, no interpolation ever touches the SQL string.
*
* @param sql SQL with {@code ?} placeholders
* @param params Values to bind in order
*/
public void executeUpdate(String sql, Object... params) {
connectionManager.validateConnection();
try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) {
if (stmt == null) return;
bindParams(stmt, params);
stmt.executeUpdate();
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error while executing update - " + ex.getMessage());
}
}
/**
* Prepares a statement for callers that need to read a {@link java.sql.ResultSet}.
* Parameters must be bound by the caller before executing.
*/
public PreparedStatement prepareStatement(String sql) { public PreparedStatement prepareStatement(String sql) {
return connectionManager.prepareStatement(sql); return connectionManager.prepareStatement(sql);
} }
// ── Helpers ───────────────────────────────────────────────────────────────
private static void bindParams(PreparedStatement stmt, Object[] params) throws SQLException {
for (int i = 0; i < params.length; i++) {
stmt.setObject(i + 1, params[i]);
}
}
} }

View file

@ -17,17 +17,24 @@ import java.util.concurrent.CopyOnWriteArrayList;
/** /**
* MySQL storage manager. * MySQL storage manager.
* *
* <p><b>Security:</b> Every query that touches user-supplied data uses
* {@code ?} placeholders bound via {@link PreparedStatement#setObject}.
* No string interpolation of player names, UUIDs, scores, or any other
* external value ever reaches the SQL string itself.</p>
*
* @since 5.0.0 * @since 5.0.0
*/ */
class StorageSQL { class StorageSQL {
private static final SQLConnectionManager connectionManager = new SQLConnectionManager(); private static final SQLConnectionManager connectionManager = new SQLConnectionManager();
private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager); private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager);
private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor); private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor);
private static boolean initialized = false; private static boolean initialized = false;
private static final List<String> pendingTableCreations = new CopyOnWriteArrayList<>(); private static final List<String> pendingTableCreations = new CopyOnWriteArrayList<>();
// ── Connection lifecycle ──────────────────────────────────────────────────
public static boolean isConnected() { public static boolean isConnected() {
return connectionManager.isConnected(); return connectionManager.isConnected();
} }
@ -36,17 +43,28 @@ class StorageSQL {
connectionManager.runWhenConnected(callback); connectionManager.runWhenConnected(callback);
} }
public static void close() {
connectionManager.close();
}
// ── Initialisation ────────────────────────────────────────────────────────
/**
* Queues creation of the leaderboard table for {@code mode}.
* Table name is constructed from a config-defined prefix (admin-controlled,
* not user input) so formatting here is safe.
*/
public static void init(String mode) { public static void init(String mode) {
// Table names are derived from admin config, not player input — safe to format.
String createTableSql = """ String createTableSql = """
CREATE TABLE IF NOT EXISTS `%s` CREATE TABLE IF NOT EXISTS `%s`
( (
uuid CHAR(36) NOT NULL PRIMARY KEY, uuid CHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(16), name VARCHAR(16),
time VARCHAR(16), time VARCHAR(16),
difficulty VARCHAR(3), difficulty VARCHAR(3),
score INT score INT
) ) CHARSET = utf8 ENGINE = InnoDB;
CHARSET = utf8 ENGINE = InnoDB;
""".formatted(getTableName(mode)); """.formatted(getTableName(mode));
if (!initialized) { if (!initialized) {
@ -58,72 +76,83 @@ class StorageSQL {
} }
} }
public static void close() { // ── Leaderboard ───────────────────────────────────────────────────────────
connectionManager.close();
}
public static @NotNull Map<UUID, Score> readScores(@NotNull String mode) { public static @NotNull Map<UUID, Score> readScores(@NotNull String mode) {
// Table name from admin config — safe to format; no user data here.
String sql = "SELECT * FROM `%s`;".formatted(getTableName(mode)); String sql = "SELECT * FROM `%s`;".formatted(getTableName(mode));
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) { try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
if (stmt == null) return new HashMap<>(); if (stmt == null) return new HashMap<>();
try (ResultSet results = stmt.executeQuery()) { try (ResultSet rs = stmt.executeQuery()) {
Map<UUID, Score> scores = new HashMap<>(); Map<UUID, Score> scores = new HashMap<>();
while (results.next()) { while (rs.next()) {
scores.put(UUID.fromString(results.getString("uuid")), new Score( scores.put(
results.getString("name"), UUID.fromString(rs.getString("uuid")),
results.getString("time"), new Score(
results.getString("difficulty"), rs.getString("name"),
results.getInt("score"))); rs.getString("time"),
rs.getString("difficulty"),
rs.getInt("score")));
} }
return scores; return scores;
} }
} catch (SQLException ex) { } catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe( LoParkour.getPlugin().getLogger().severe(
"Error while trying to read SQL data of %s - %s".formatted(mode, ex.getMessage())); "Error reading leaderboard for mode %s - %s".formatted(mode, ex.getMessage()));
return new HashMap<>(); return new HashMap<>();
} }
} }
/**
* Writes all scores for a mode.
*
* <p>Uses an INSERT … ON DUPLICATE KEY UPDATE with {@code ?} placeholders.
* Player name, time, difficulty come from the leaderboard record — all bound
* as parameters, never interpolated.</p>
*/
public static void writeScores(@NotNull String mode, @NotNull Map<UUID, Score> scores) { public static void writeScores(@NotNull String mode, @NotNull Map<UUID, Score> scores) {
// Table name is from admin config — safe to format.
String sql = """ String sql = """
INSERT INTO `%s` INSERT INTO `%s` (uuid, name, time, difficulty, score)
(uuid, name, time, difficulty, score)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), ON DUPLICATE KEY UPDATE
time = VALUES(time), name = VALUES(name),
difficulty = VALUES(difficulty), time = VALUES(time),
score = VALUES(score); difficulty = VALUES(difficulty),
score = VALUES(score);
""".formatted(getTableName(mode)); """.formatted(getTableName(mode));
new HashMap<>(scores).forEach((uuid, score) -> { new HashMap<>(scores).forEach((uuid, score) ->
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) { queryExecutor.executeUpdate(sql,
if (stmt == null) return; uuid.toString(),
stmt.setString(1, uuid.toString()); score.name(),
stmt.setString(2, score.name()); score.time(),
stmt.setString(3, score.time()); score.difficulty(),
stmt.setString(4, score.difficulty()); score.score()));
stmt.setInt(5, score.score());
stmt.executeUpdate();
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error writing score for %s in mode %s - %s".formatted(uuid, mode, ex.getMessage()));
}
});
} }
private static String getTableName(String mode) { // ── Player settings ───────────────────────────────────────────────────────
return "%sleaderboard-%s".formatted(Option.SQL_PREFIX, mode);
}
/**
* Reads player settings from the options table.
*
* <p>UUID is bound as a parameter — never interpolated into the query string.</p>
*/
public static void readPlayer(@NotNull ParkourPlayer player) { public static void readPlayer(@NotNull ParkourPlayer player) {
String sql = "SELECT * FROM `%soptions` WHERE uuid = '%s';".formatted(Option.SQL_PREFIX, player.getUUID()); // Table name from admin config — safe. UUID bound as parameter below.
String sql = "SELECT * FROM `%soptions` WHERE uuid = ?;".formatted(Option.SQL_PREFIX);
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) { try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
if (stmt == null) { if (stmt == null) {
player.setSettings(new HashMap<>()); player.setSettings(new HashMap<>());
return; return;
} }
try (ResultSet results = stmt.executeQuery()) {
if (!results.next()) { // Bind UUID as parameter — no injection possible
stmt.setString(1, player.getUUID().toString());
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) {
player.setSettings(new HashMap<>()); player.setSettings(new HashMap<>());
return; return;
} }
@ -131,61 +160,78 @@ class StorageSQL {
Map<String, Object> settings = new HashMap<>(); Map<String, Object> settings = new HashMap<>();
for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) { for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) {
try { try {
settings.put(key, results.getObject(key)); settings.put(key, rs.getObject(key));
} catch (SQLException ex) { } catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe( LoParkour.getPlugin().getLogger().severe(
"Error reading SQL data of %s, key=%s - %s".formatted(player.getName(), key, ex.getMessage())); "Error reading SQL column %s for %s - %s"
.formatted(key, player.getName(), ex.getMessage()));
} }
} }
player.setSettings(settings); player.setSettings(settings);
} }
} catch (SQLException ex) { } catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe( LoParkour.getPlugin().getLogger().severe(
"Error reading SQL data of %s - %s".formatted(player.getName(), ex.getMessage())); "Error reading player data for %s - %s".formatted(player.getName(), ex.getMessage()));
} }
} }
/**
* Writes player settings to the options table.
*
* <p>All 12 player-controlled values (style, locale, rewards, etc.) are bound
* as {@code ?} parameters. None of them are interpolated into the SQL string.</p>
*/
public static void writePlayer(@NotNull ParkourPlayer player) { public static void writePlayer(@NotNull ParkourPlayer player) {
DecimalFormat df = new DecimalFormat("#.######", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); DecimalFormat df = new DecimalFormat("#.######",
String schematicDifficulty = df.format(player.schematicDifficulty); DecimalFormatSymbols.getInstance(Locale.ENGLISH));
double schemDiff = df.format(player.schematicDifficulty) != null
? player.schematicDifficulty : 0.0;
// Table name from admin config — safe to format.
String sql = """ String sql = """
INSERT INTO `%soptions` INSERT INTO `%soptions`
(uuid, style, blockLead, useParticles, useSpecial, showFallMsg, showScoreboard, (uuid, style, blockLead, useParticles, useSpecial, showFallMsg,
selectedTime, collectedRewards, locale, schematicDifficulty, sound) showScoreboard, selectedTime, collectedRewards, locale,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) schematicDifficulty, sound)
ON DUPLICATE KEY UPDATE style = VALUES(style), VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
blockLead = VALUES(blockLead), ON DUPLICATE KEY UPDATE
useParticles = VALUES(useParticles), style = VALUES(style),
useSpecial = VALUES(useSpecial), blockLead = VALUES(blockLead),
showFallMsg = VALUES(showFallMsg), useParticles = VALUES(useParticles),
showScoreboard = VALUES(showScoreboard), useSpecial = VALUES(useSpecial),
selectedTime = VALUES(selectedTime), showFallMsg = VALUES(showFallMsg),
collectedRewards = VALUES(collectedRewards), showScoreboard = VALUES(showScoreboard),
locale = VALUES(locale), selectedTime = VALUES(selectedTime),
schematicDifficulty = VALUES(schematicDifficulty), collectedRewards = VALUES(collectedRewards),
sound = VALUES(sound); locale = VALUES(locale),
schematicDifficulty = VALUES(schematicDifficulty),
sound = VALUES(sound);
""".formatted(Option.SQL_PREFIX); """.formatted(Option.SQL_PREFIX);
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) { queryExecutor.executeUpdate(sql,
if (stmt == null) return; player.getUUID().toString(), // 1 uuid
stmt.setString(1, player.getUUID().toString()); player.style, // 2 style
stmt.setString(2, player.style); player.blockLead, // 3 blockLead
stmt.setInt(3, player.blockLead); player.particles, // 4 useParticles
stmt.setBoolean(4, player.particles); player.useSpecialBlocks, // 5 useSpecial
stmt.setBoolean(5, player.useSpecialBlocks); player.showFallMessage, // 6 showFallMsg
stmt.setBoolean(6, player.showFallMessage); player.showScoreboard, // 7 showScoreboard
stmt.setBoolean(7, player.showScoreboard); player.selectedTime, // 8 selectedTime
stmt.setInt(8, player.selectedTime); String.join(",", player.collectedRewards), // 9 collectedRewards
stmt.setString(9, String.join(",", player.collectedRewards)); player.locale, // 10 locale
stmt.setString(10, player.locale); schemDiff, // 11 schematicDifficulty
stmt.setString(11, schematicDifficulty); player.sound // 12 sound
stmt.setBoolean(12, player.sound); );
stmt.executeUpdate(); }
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe( // ── Internals ─────────────────────────────────────────────────────────────
"Error writing player data for %s - %s".formatted(player.getName(), ex.getMessage()));
} /**
* Table name is built from the admin-configured SQL prefix and the internal
* mode name (e.g. "default", "speedrun"). Neither is user-supplied.
*/
private static String getTableName(String mode) {
return "%sleaderboard-%s".formatted(Option.SQL_PREFIX, mode);
} }
private static void connect() { private static void connect() {
@ -193,7 +239,7 @@ class StorageSQL {
migrationManager.initializeDatabase(); migrationManager.initializeDatabase();
for (String sql : pendingTableCreations) { for (String sql : pendingTableCreations) {
queryExecutor.executeUpdate(sql); queryExecutor.executeStaticUpdate(sql);
} }
pendingTableCreations.clear(); pendingTableCreations.clear();
} }

View file

@ -318,13 +318,8 @@ ghost-mode:
# -= Game modes options =- # -= Game modes options =-
modes: modes:
# -= Default mode =-
default:
enabled: true
# -= Speedrun mode =- # -= Speedrun mode =-
speedrun: speedrun:
enabled: true
# Block lifetime in seconds before disappearing # Block lifetime in seconds before disappearing
block-lifetime: 1.5 block-lifetime: 1.5
# Warning time in seconds before block disappears # Warning time in seconds before block disappears
@ -332,7 +327,6 @@ modes:
# -= Gravity shift mode =- # -= Gravity shift mode =-
gravity-shift: gravity-shift:
enabled: true
# Interval in jumps before effect changes # Interval in jumps before effect changes
interval: 10 interval: 10
@ -356,24 +350,38 @@ modes:
# -= Hardcore mode =- # -= Hardcore mode =-
hardcore: hardcore:
enabled: false
# Reset all rewards on fall # Reset all rewards on fall
reset-rewards: true reset-rewards: true
# -= Elytra mode =- # -= Elytra mode =-
elytra: elytra:
# Enable elytra mode # Enable elytra mode
enabled: false enabled: true
# Distance between rings
# --- Ring course settings ---
# Minimum distance (blocks) between consecutive rings
ring-distance-min: 20 ring-distance-min: 20
# Maximum distance (blocks) between consecutive rings
ring-distance-max: 40 ring-distance-max: 40
# Ring size (radius) # Ring radius in blocks. Player must fly through the center hole.
ring-size: 5 ring-size: 5
# Firework boost power # How many rings to generate ahead of the player
ring-lead: 8
# Maximum horizontal heading change per ring in degrees (course curvature)
max-heading-change-horizontal: 30
# Maximum vertical heading change per ring in degrees
max-heading-change-vertical: 15
# Maximum Y blocks above spawn point that the course can reach
max-height-above-spawn: 60
# How far from the course (blocks) before a fall is triggered
max-deviation: 25
# --- Firework / boost settings ---
# Firework velocity boost multiplier
boost-power: 1.5 boost-power: 1.5
# Firework cooldown in milliseconds # Cooldown between firework boosts in milliseconds
firework-cooldown-ms: 3000 firework-cooldown-ms: 3000
# Starting fireworks amount # Number of fireworks given at session start
starting-fireworks: 64 starting-fireworks: 64
# -= Race mode =- # -= Race mode =-
@ -386,7 +394,7 @@ modes:
# -= Co-op mode =- # -= Co-op mode =-
coop: coop:
# Enable co-op mode # Enable co-op mode
enabled: false enabled: true
# Maximum players in co-op session # Maximum players in co-op session
max-players: 4 max-players: 4

View file

@ -158,20 +158,21 @@ settings:
lore: "&#00D4FFCurrently: &#A0A0A0%s||&#A0A0A0Your current visual time." lore: "&#00D4FFCurrently: &#A0A0A0%s||&#A0A0A0Your current visual time."
schematics: schematics:
material: "paper" material: ""
name: "&#00D4FF&lSchematic difficulty" name: "&#00D4FF&lSchematic difficulty"
lore: "&#00D4FFCurrently: &#A0A0A0%s||&#A0A0A0The difficulty of schematics" lore: "&#00D4FFCurrently: &#A0A0A0%s||&#A0A0A0The difficulty of schematics"
values: ["&#FF4444off", "&#00D4FFeasy", "&#FFFF00medium", "&#00D4FFhard", "&#8B0000very hard"] values: ["&#FF4444off", "&#00D4FFeasy", "&#FFFF00medium", "&#00D4FFhard", "&#8B0000very hard"]
scoreboard: scoreboard:
material: "oak_sign" material: ""
name: "Show scoreboard" name: "Show scoreboard"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled shows the scoreboard." lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled shows the scoreboard."
fall_message: fall_message:
material: "feather" item:
name: "Show fall message" material: ""
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled shows a message when you fall||&#A0A0A0with extra info." name: "Show fall message"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled shows a message when you fall||&#A0A0A0with extra info."
divider: "&#404040&m-----------------------------------" divider: "&#404040&m-----------------------------------"
score: "&#00D4FF&lScore &r&#FFFFFF%s" score: "&#00D4FF&lScore &r&#FFFFFF%s"
@ -184,22 +185,22 @@ settings:
tied: "&#A0A0A0You tied your high score!" tied: "&#A0A0A0You tied your high score!"
particles: particles:
material: "blaze_powder" material: ""
name: "Show particles" name: "Show particles"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will show particles." lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will show particles."
sound: sound:
material: "note_block" material: ""
name: "Play sounds" name: "Play sounds"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will play a sound." lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will play a sound."
special_blocks: special_blocks:
material: "ice" material: ""
name: "Use special blocks" name: "Use special blocks"
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, uses special blocks like ice and slabs." lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, uses special blocks like ice and slabs."
score_difficulty: score_difficulty:
material: "experience_bottle" material: ""
name: "Score difficulty" name: "Score difficulty"
lore: "&#A0A0A0Currently: %s||&#A0A0A0When enabled, the higher score you have,||&#A0A0A0the more difficult it gets." lore: "&#A0A0A0Currently: %s||&#A0A0A0When enabled, the higher score you have,||&#A0A0A0the more difficult it gets."
@ -257,8 +258,6 @@ other:
no_do: "&#A0A0A0You can't do this right now." no_do: "&#A0A0A0You can't do this right now."
no_teleporting: "&#FFFFFFYou can't enter the parkour world by teleporting!" no_teleporting: "&#FFFFFFYou can't enter the parkour world by teleporting!"
customize: "&#A0A0A0Customize your parkour experience by using \"/parkour\"" customize: "&#A0A0A0Customize your parkour experience by using \"/parkour\""
joining_disabled: "&#FF6464Joining is currently disabled."
no_menu_items: "&#FF6464No menu items available. Check your permissions and locale files."
quit: quit:
material: "barrier" material: "barrier"
@ -278,15 +277,19 @@ other:
modes: modes:
elytra: elytra:
material: "elytra" material: "elytra"
name: "&#00D4FF&lElytra &#FF6464&l[BETA]" name: "&#00D4FF&lElytra"
lore: "&#A0A0A0Fly through rings!||&#A0A0A0Use fireworks to boost||&#FF6464Mode in development" lore: "&#A0A0A0Fly through rings!||&#A0A0A0Use fireworks to boost"
start_hint: "&#A0A0A0Fly through the rings! Use fireworks to boost."
race: race:
material: "golden_boots" material: "golden_boots"
name: "&#FFD700&lRace" name: "&#FFD700&lRace"
lore: "&#A0A0A0First to reach target score!||&#A0A0A0Compete for fastest time" lore: "&#A0A0A0First to reach target score!||&#A0A0A0Compete for fastest time"
completed: "&#FFD700Finished! Score: &#FFFFFF%s &#A0A0Ain &#FFFFFF%s"
coop: coop:
material: "player_head" material: "player_head"
name: "&#00FF00&lCo-op" name: "&#00FF00&lCo-op"
lore: "&#A0A0A0Team up with friends!||&#A0A0A0Shared score and progress" lore: "&#A0A0A0Team up with friends!||&#A0A0A0Shared score and progress"
player_joined: "&#00FF00%s &#A0A0Ajoined the co-op session!"
stats: "&#A0A0ATeam score: &#FFFFFF%s &#A0A0A| Your blocks: &#FFFFFF%s &#A0A0A(&#FFFFFF%s%%&#A0A0A)"

View file

@ -1,7 +1,7 @@
# Русский (Russian, ru) # Русский (Russian, ru)
# By loki # By loki
# Материалы должны быть из: https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Material.html # Материалы должны быть из: https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Material.html
# Узнать больше о цветах: https://docs.adventure.kyori.net/minimessage/format.html#format # Узнайте больше о цветах: https://docs.adventure.kyori.net/minimessage/format.html#format
name: "Русский" name: "Русский"
@ -17,7 +17,7 @@ play:
lore: "" lore: ""
single: single:
name: "&#FFFFFFОдиночный" name: "&#FFFFFFОдиночная игра"
item: item:
material: "ender_pearl" material: "ender_pearl"
@ -32,7 +32,7 @@ play:
speedrun: speedrun:
material: "clock" material: "clock"
name: "&#00D4FF&lСпидран" name: "&#00D4FF&lСпидран"
lore: "&#404040Гонка на время! Блоки||&#404040исчезают после того, как вы на них наступите." lore: "&#404040Гонка со временем! Блоки||&#404040исчезают после того, как вы на них встанете."
gravity-shift: gravity-shift:
material: "feather" material: "feather"
@ -42,7 +42,7 @@ play:
hardcore: hardcore:
material: "skeleton_skull" material: "skeleton_skull"
name: "&#00D4FF&lХардкор" name: "&#00D4FF&lХардкор"
lore: "&#404040Одна ошибка и всё! Нет||&#404040второго шанса." lore: "&#404040Одна ошибка и всё кончено!||&#404040Второго шанса не будет."
spectator: spectator:
name: "&#FFFFFFНаблюдатель" name: "&#FFFFFFНаблюдатель"
@ -83,12 +83,12 @@ community:
material: "bookshelf" material: "bookshelf"
name: "&#00D4FF&lСортировка по %s" name: "&#00D4FF&lСортировка по %s"
lore: "" lore: ""
values: ["счёту", "времени", "сложности"] values: ["очкам", "времени", "сложности"]
head: head:
material: "" material: ""
name: "&#00D4FF&l#%r - %p" name: "&#00D4FF&l#%r - %p"
lore: "&#00D4FFСчёт: &#A0A0A0%s||&#00D4FFВремя: &#A0A0A0%t||&#00D4FFСложность: &#A0A0A0%d&#404040/1.0" lore: "&#00D4FFОчки: &#A0A0A0%s||&#00D4FFВремя: &#A0A0A0%t||&#00D4FFСложность: &#A0A0A0%d&#404040/1.0"
settings: settings:
name: "&#FFFFFFНастройки" name: "&#FFFFFFНастройки"
@ -109,7 +109,7 @@ settings:
chat: chat:
material: "crossbow" material: "crossbow"
name: "&#00D4FF&lЧат" name: "&#00D4FF&lЧат"
lore: "&#404040Текущий: &#00D4FF&l%s||&#404040Выберите с кем вы можете общаться" lore: "&#404040Текущий: &#00D4FF&l%s||&#404040Выберите, с кем вы можете общаться"
values: ["только лобби", "только игроки", "публичный"] values: ["только лобби", "только игроки", "публичный"]
formats: formats:
@ -131,7 +131,7 @@ settings:
no_change: no_change:
material: "barrier" material: "barrier"
name: "&#8B0000&lВы не можете изменить это сейчас!" name: "&#8B0000&lВы не можете изменить это сейчас!"
lore: "&#A0A0A0У вас счёт больше 0,||&#A0A0A0поэтому вы не можете изменить эту настройку." lore: "&#A0A0A0У вас есть очки выше 0,||&#A0A0A0что означает, что вы не можете изменить эту настройку."
styles: styles:
name: "&#FFFFFFСтили" name: "&#FFFFFFСтили"
@ -144,12 +144,12 @@ settings:
item: item:
material: "crafting_table" material: "crafting_table"
name: "&#00D4FF&lСтиль" name: "&#00D4FF&lСтиль"
lore: "&#00D4FFТекущий: &#A0A0A0%s||&#A0A0A0Выберите какие блоки будут использоваться." lore: "&#00D4FFТекущий: &#A0A0A0%s||&#A0A0A0Выберите, какие блоки будут использоваться."
leads: leads:
material: "feather" material: "feather"
name: "&#00D4FF&lДистанция" name: "&#00D4FF&lОпережение"
lore: "&#00D4FFТекущая: &#A0A0A0%s блоков||&#A0A0A0Сколько блоков будет||&#A0A0A0сгенерировано впереди вас." lore: "&#00D4FFТекущее: &#A0A0A0%s блоков||&#A0A0A0Сколько блоков будет||&#A0A0A0сгенерировано перед вами."
time: time:
material: "clock" material: "clock"
@ -157,50 +157,51 @@ settings:
lore: "&#00D4FFТекущее: &#A0A0A0%s||&#A0A0A0Ваше текущее визуальное время." lore: "&#00D4FFТекущее: &#A0A0A0%s||&#A0A0A0Ваше текущее визуальное время."
schematics: schematics:
material: "paper" material: ""
name: "&#00D4FF&lСложность схематик" name: "&#00D4FF&lСложность схематик"
lore: "&#00D4FFТекущая: &#A0A0A0%s||&#A0A0A0Сложность схематик" lore: "&#00D4FFТекущая: &#A0A0A0%s||&#A0A0A0Сложность схематик"
values: ["&#FF4444выкл", "&#00D4FFлёгкая", "&#FFFF00средняя", "&#00D4FFсложная", "&#8B0000очень сложная"] values: ["&#FF4444выкл", "&#00D4FFлегко", "&#FFFF00средне", "&#00D4FFсложно", "&#8B0000очень сложно"]
scoreboard: scoreboard:
material: "oak_sign" material: ""
name: "Показывать табло" name: "Показывать табло"
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, показывает табло." lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, показывает табло."
fall_message: fall_message:
material: "feather" item:
name: "Показывать сообщение о падении" material: ""
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, показывает сообщение при падении||&#A0A0A0с дополнительной информацией." name: "Показывать сообщение о падении"
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, показывает сообщение при падении||&#A0A0A0с дополнительной информацией."
divider: "&#404040&m-----------------------------------" divider: "&#404040&m-----------------------------------"
score: "&#00D4FF&lСчёт &r&#FFFFFF%s" score: "&#00D4FF&lОчки &r&#FFFFFF%s"
time: "&#00D4FF&lВремя &r&#FFFFFF%s" time: "&#00D4FF&lВремя &r&#FFFFFF%s"
high_score: "&#00D4FF&lРекорд &r&#FFFFFF%s" high_score: "&#00D4FF&lРекорд &r&#FFFFFF%s"
formats: formats:
miss: "&#A0A0A0Вы не дотянули до рекорда на %s очков!" miss: "&#A0A0A0Вы не достигли своего рекорда на %s очков!"
beat: "&#A0A0A0Вы побили свой рекорд на %s очков!" beat: "&#A0A0A0Вы побили свой рекорд на %s очков!"
tied: "&#A0A0A0Вы повторили свой рекорд!" tied: "&#A0A0A0Вы повторили свой рекорд!"
particles: particles:
material: "blaze_powder" material: ""
name: "Показывать частицы" name: "Показывать частицы"
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет показывать частицы." lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет показывать частицы."
sound: sound:
material: "note_block" material: ""
name: "Воспроизводить звуки" name: "Воспроизводить звуки"
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет воспроизводить звук." lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет воспроизводить звук."
special_blocks: special_blocks:
material: "ice" material: ""
name: "Использовать специальные блоки" name: "Использовать специальные блоки"
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, использует специальные блоки||&#A0A0A0такие как лёд и плиты." lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, использует специальные блоки, такие как лёд и плиты."
score_difficulty: score_difficulty:
material: "experience_bottle" material: ""
name: "Сложность от счёта" name: "Сложность по очкам"
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, чем выше ваш счёт,||&#A0A0A0тем сложнее становится." lore: "&#A0A0A0Текущее: %s||&#A0A0A0Когда включено, чем выше ваш счёт,||&#A0A0A0тем сложнее становится."
lobby: lobby:
name: "&#FFFFFFЛобби" name: "&#FFFFFFЛобби"
@ -216,28 +217,28 @@ lobby:
visibility: visibility:
material: "firework_rocket" material: "firework_rocket"
name: "&#00D4FF&lВидимость" name: "&#00D4FF&lВидимость"
lore: "&#404040Текущая: &#00D4FF&l%s||&#404040Выберите кто может присоединиться к вашему лобби." lore: "&#404040Текущая: &#00D4FF&l%s||&#404040Выберите, кто может присоединиться к вашему лобби."
values: ["приватное", "только по приглашению", "публичное"] values: ["приватное", "только по приглашению", "публичное"]
player_management: player_management:
name: "&#FFFFFFУправление игроками" name: "&#FFFFFFУправление игроками"
kicked: "&#A0A0A0Вы были кикнуты из лобби владельцем." kicked: "&#A0A0A0Вы были исключены из лобби владельцем."
advice: "&#A0A0A0Установите видимость лобби на 'только по приглашению' или 'приватное' чтобы избежать случайных присоединений." advice: "&#A0A0A0Установите видимость лобби на 'только по приглашению' или 'приватное', чтобы избежать случайных присоединений."
muted: "&#A0A0A0Вы были замучены владельцем лобби." muted: "&#A0A0A0Вы были заглушены владельцем лобби."
unmuted: "&#A0A0A0С вас сняли мут владелец лобби." unmuted: "&#A0A0A0Ваш голос был разблокирован владельцем лобби."
item: item:
material: "cake" material: "cake"
name: "&#00D4FF&lУправление игроками" name: "&#00D4FF&lУправление игроками"
lore: "&#404040Кикайте или мутьте игроков здесь." lore: "&#404040Исключайте или заглушайте игроков здесь."
head: head:
material: "" material: ""
name: "&#00D4FF&l%s" name: "&#00D4FF&l%s"
lore: "" lore: ""
top: "&#404040Замучен в лобби||" top: "&#404040Заглушён в лобби||"
bottom: "&#00D4FFЛевый клик&#404040 чтобы кикнуть из лобби||&#00D4FFПравый клик&#404040 чтобы замутить/размутить в лобби" bottom: "&#00D4FFЛевый клик&#404040 чтобы исключить из лобби||&#00D4FFПравый клик&#404040 чтобы заглушить/разблокировать в лобби"
scoreboard: scoreboard:
@ -245,7 +246,7 @@ scoreboard:
lines: lines:
- "&#0000FF" - "&#0000FF"
- "&#00D4FFСчёт &#FFFFFF%score%" - "&#00D4FFОчки &#FFFFFF%score%"
- "&#00D4FFВремя &#FFFFFF%time%" - "&#00D4FFВремя &#FFFFFF%time%"
- "&#00D4FFРекорд &#FFFFFF%high_score%" - "&#00D4FFРекорд &#FFFFFF%high_score%"
- "&#FF4444" - "&#FF4444"
@ -255,13 +256,11 @@ other:
no_do: "&#A0A0A0Вы не можете сделать это сейчас." no_do: "&#A0A0A0Вы не можете сделать это сейчас."
no_teleporting: "&#FFFFFFВы не можете войти в мир паркура телепортацией!" no_teleporting: "&#FFFFFFВы не можете войти в мир паркура телепортацией!"
customize: "&#A0A0A0Настройте свой паркур используя \"/parkour\"" customize: "&#A0A0A0Настройте свой паркур, используя \"/parkour\""
joining_disabled: "&#FF6464Вход в паркур временно отключён."
no_menu_items: "&#FF6464Нет доступных пунктов меню. Проверьте права доступа и файлы локализации."
quit: quit:
material: "barrier" material: "barrier"
name: "&#00D4FF&lВыход" name: "&#00D4FF&lВыйти"
lore: "" lore: ""
close: close:
@ -277,15 +276,22 @@ other:
modes: modes:
elytra: elytra:
material: "elytra" material: "elytra"
name: "&#00D4FF&lЭлитры &#FF6464&l[BETA]" name: "&#00D4FF&lЭлитры"
lore: "&#A0A0A0Летайте через кольца!||&#A0A0A0Используйте фейерверки для ускорения||&#FF6464Режим в разработке" lore: "&#A0A0A0Летайте через кольца!||&#A0A0A0Используйте фейерверки для ускорения"
start_hint: "&#A0A0A0Летите через кольца! Используйте фейерверки для ускорения."
race: race:
material: "golden_boots" material: "golden_boots"
name: "&#FFD700&lГонка" name: "&#FFD700&lГонка"
lore: "&#A0A0A0Первый до целевого счёта!||&#A0A0A0Соревнуйся за лучшее время" lore: "&#A0A0A0Первым достигните целевого счёта!||&#A0A0A0Соревнуйтесь за лучшее время"
completed: "&#FFD700Финиш! Счёт: &#FFFFFF%s &#A0A0Aза &#FFFFFF%s"
coop: coop:
material: "player_head" material: "player_head"
name: "&#00FF00&lКооп" name: "&#00FF00&lКооператив"
lore: "&#A0A0A0Играй с друзьями!||&#A0A0A0Общий счёт и прогресс" lore: "&#A0A0A0Объединитесь с друзьями!||&#A0A0A0Общий счёт и прогресс"
player_joined: "&#00FF00%s &#A0A0Aприсоединился к кооперативной сессии!"
stats: "&#A0A0AКомандный счёт: &#FFFFFF%s &#A0A0A| Ваши блоки: &#FFFFFF%s &#A0A0A(&#FFFFFF%s%%&#A0A0A)"
ghost:
saved: "&#00D4FFВаш призрак сохранён с результатом %s очков!"

View file

@ -137,4 +137,16 @@ class JumpValidatorTest {
assertFalse(validator.canJump(from, to), "10 block jump should be impossible"); assertFalse(validator.canJump(from, to), "10 block jump should be impossible");
} }
@Test
void testCanJump_FromBottomSlab_ReducedUpwardCapability() {
// Test that bottom slabs reduce upward jump capability
// This test uses mock objects, so the slab detection won't work,
// but it documents the expected behavior
Location from = new Location(world, 0, 100, 0);
Location to = new Location(world, 2, 101, 0);
// This should still pass with mock objects (no slab detection)
assertTrue(validator.canJump(from, to), "2 block jump with 1 up should be possible from mock location");
}
} }

View file

@ -8,6 +8,7 @@ import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@ -24,10 +25,11 @@ class GhostDataTest {
frames.add(new GhostFrame(0, 0, 100, 0, 0, 0)); frames.add(new GhostFrame(0, 0, 100, 0, 0, 0));
frames.add(new GhostFrame(1000, 1, 100, 0, 90, 0)); frames.add(new GhostFrame(1000, 1, 100, 0, 90, 0));
GhostData data = new GhostData("TestPlayer", "uuid-123", 42, frames); UUID uuid = UUID.randomUUID();
GhostData data = new GhostData(uuid, "TestPlayer", 42, frames);
assertEquals("TestPlayer", data.getPlayerName()); assertEquals("TestPlayer", data.getPlayerName());
assertEquals("uuid-123", data.getPlayerUUID()); assertEquals(uuid, data.getPlayerUuid());
assertEquals(42, data.getScore()); assertEquals(42, data.getScore());
assertEquals(2, data.getFrames().size()); assertEquals(2, data.getFrames().size());
} }
@ -40,7 +42,8 @@ class GhostDataTest {
frames.add(new GhostFrame(500, 1, 100, 0, 45, 0)); frames.add(new GhostFrame(500, 1, 100, 0, 45, 0));
frames.add(new GhostFrame(1000, 2, 100, 0, 90, 0)); frames.add(new GhostFrame(1000, 2, 100, 0, 90, 0));
GhostData original = new GhostData("Player1", "uuid-456", 100, frames); UUID uuid = UUID.randomUUID();
GhostData original = new GhostData(uuid, "Player1", 100, frames);
// Save to file // Save to file
File file = tempDir.resolve("test.ghost").toFile(); File file = tempDir.resolve("test.ghost").toFile();
@ -54,7 +57,7 @@ class GhostDataTest {
// Verify data // Verify data
assertEquals(original.getPlayerName(), loaded.getPlayerName()); assertEquals(original.getPlayerName(), loaded.getPlayerName());
assertEquals(original.getPlayerUUID(), loaded.getPlayerUUID()); assertEquals(original.getPlayerUuid(), loaded.getPlayerUuid());
assertEquals(original.getScore(), loaded.getScore()); assertEquals(original.getScore(), loaded.getScore());
assertEquals(original.getFrames().size(), loaded.getFrames().size()); assertEquals(original.getFrames().size(), loaded.getFrames().size());
@ -75,7 +78,8 @@ class GhostDataTest {
@Test @Test
void testEmptyFrames(@TempDir Path tempDir) throws IOException { void testEmptyFrames(@TempDir Path tempDir) throws IOException {
// Test with no frames // Test with no frames
GhostData data = new GhostData("EmptyPlayer", "uuid-789", 0, new ArrayList<>()); UUID uuid = UUID.randomUUID();
GhostData data = new GhostData(uuid, "EmptyPlayer", 0, new ArrayList<>());
File file = tempDir.resolve("empty.ghost").toFile(); File file = tempDir.resolve("empty.ghost").toFile();
data.saveToFile(file); data.saveToFile(file);
@ -101,7 +105,8 @@ class GhostDataTest {
)); ));
} }
GhostData data = new GhostData("LongRunner", "uuid-long", 1200, frames); UUID uuid = UUID.randomUUID();
GhostData data = new GhostData(uuid, "LongRunner", 1200, frames);
File file = tempDir.resolve("large.ghost").toFile(); File file = tempDir.resolve("large.ghost").toFile();
data.saveToFile(file); data.saveToFile(file);
@ -115,9 +120,10 @@ class GhostDataTest {
@Test @Test
void testSpecialCharactersInName(@TempDir Path tempDir) throws IOException { void testSpecialCharactersInName(@TempDir Path tempDir) throws IOException {
// Test with special characters in player name // Test with special characters in player name
UUID uuid = UUID.randomUUID();
GhostData data = new GhostData( GhostData data = new GhostData(
uuid,
"Player_123-ABC", "Player_123-ABC",
"uuid-special",
50, 50,
List.of(new GhostFrame(0, 0, 100, 0, 0, 0)) List.of(new GhostFrame(0, 0, 100, 0, 0, 0))
); );
@ -137,7 +143,8 @@ class GhostDataTest {
frames.add(new GhostFrame(0, -100, 50, -200, -90, -45)); frames.add(new GhostFrame(0, -100, 50, -200, -90, -45));
frames.add(new GhostFrame(1000, -99, 51, -199, -45, 0)); frames.add(new GhostFrame(1000, -99, 51, -199, -45, 0));
GhostData data = new GhostData("NegativePlayer", "uuid-neg", 10, frames); UUID uuid = UUID.randomUUID();
GhostData data = new GhostData(uuid, "NegativePlayer", 10, frames);
File file = tempDir.resolve("negative.ghost").toFile(); File file = tempDir.resolve("negative.ghost").toFile();
data.saveToFile(file); data.saveToFile(file);
@ -168,7 +175,8 @@ class GhostDataTest {
List<GhostFrame> originalFrames = new ArrayList<>(); List<GhostFrame> originalFrames = new ArrayList<>();
originalFrames.add(new GhostFrame(0, 0, 100, 0, 0, 0)); originalFrames.add(new GhostFrame(0, 0, 100, 0, 0, 0));
GhostData data = new GhostData("Player", "uuid", 10, originalFrames); UUID uuid = UUID.randomUUID();
GhostData data = new GhostData(uuid, "Player", 10, originalFrames);
// Modify original list // Modify original list
originalFrames.add(new GhostFrame(1000, 1, 100, 0, 0, 0)); originalFrames.add(new GhostFrame(1000, 1, 100, 0, 0, 0));