Intial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
target/
|
||||||
|
.m2/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# MultiSpawn-Core
|
||||||
|
|
||||||
|
Аддон для Paper 1.21.11 и Multiverse-Core 5.7.1. Для каждой пары
|
||||||
|
«UUID игрока + мир» сохраняется отдельная последняя позиция. При возвращении
|
||||||
|
в мир или перезаходе игрок появляется там, где остановился.
|
||||||
|
|
||||||
|
## Что делает плагин
|
||||||
|
|
||||||
|
- хранит координаты, поворот головы и наклон в SQLite;
|
||||||
|
- пишет в базу асинхронно, не задерживая серверный поток;
|
||||||
|
- работает только с мирами, импортированными в Multiverse-Core;
|
||||||
|
- не вмешивается в Nether/End-порталы, End Gateway и portal-события
|
||||||
|
Multiverse-Core;
|
||||||
|
- по умолчанию игнорирует `world`, `world_nether` и `world_the_end`.
|
||||||
|
|
||||||
|
База создаётся по пути:
|
||||||
|
`plugins/MultiSpawn-Core/positions.db`.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Paper 1.21.11;
|
||||||
|
- Java 21 или новее;
|
||||||
|
- Multiverse-Core 5.7.1.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```shell
|
||||||
|
mvn clean package
|
||||||
|
```
|
||||||
|
|
||||||
|
Готовый JAR: `target/MultiSpawn-Core-1.0.0.jar`.
|
||||||
|
|
||||||
|
## Настройка
|
||||||
|
|
||||||
|
В `config.yml` доступны белый список `enabled-worlds`, чёрный список
|
||||||
|
`disabled-worlds` и переключатель `include-default-worlds`. Обычные миры
|
||||||
|
начнут сохраняться только после включения `include-default-worlds: true`.
|
||||||
|
|
||||||
|
Перезагрузка настроек: `/multispawn reload`.
|
||||||
101
pom.xml
Normal file
101
pom.xml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>ru.multispawn</groupId>
|
||||||
|
<artifactId>multispawn-core</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>MultiSpawn-Core</name>
|
||||||
|
<description>Per-world player return positions for Multiverse-Core.</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>papermc</id>
|
||||||
|
<url>https://repo.papermc.io/repository/maven-public/</url>
|
||||||
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>onarandombox</id>
|
||||||
|
<url>https://repo.onarandombox.com/content/groups/public/</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.papermc.paper</groupId>
|
||||||
|
<artifactId>paper-api</artifactId>
|
||||||
|
<version>1.21.11-R0.1-SNAPSHOT</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mvplugins.multiverse.core</groupId>
|
||||||
|
<artifactId>multiverse-core</artifactId>
|
||||||
|
<version>5.7.1</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
|
<version>3.50.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.13.4</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>MultiSpawn-Core-${project.version}</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.14.0</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||||
|
<!-- sqlite-jdbc loads platform natives dynamically; keep them all. -->
|
||||||
|
<minimizeJar>false</minimizeJar>
|
||||||
|
<filters>
|
||||||
|
<filter>
|
||||||
|
<artifact>*:*</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>META-INF/*.SF</exclude>
|
||||||
|
<exclude>META-INF/*.DSA</exclude>
|
||||||
|
<exclude>META-INF/*.RSA</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
</filters>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
82
src/main/java/ru/multispawn/core/MultiSpawnPlugin.java
Normal file
82
src/main/java/ru/multispawn/core/MultiSpawnPlugin.java
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package ru.multispawn.core;
|
||||||
|
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.mvplugins.multiverse.core.MultiverseCoreApi;
|
||||||
|
import ru.multispawn.core.storage.PositionStore;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
public final class MultiSpawnPlugin extends JavaPlugin {
|
||||||
|
private PositionStore positionStore;
|
||||||
|
private WorldPolicy worldPolicy;
|
||||||
|
private PlayerPositionListener listener;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnable() {
|
||||||
|
saveDefaultConfig();
|
||||||
|
|
||||||
|
final MultiverseCoreApi multiverse;
|
||||||
|
try {
|
||||||
|
multiverse = MultiverseCoreApi.get();
|
||||||
|
} catch (IllegalStateException exception) {
|
||||||
|
getLogger().log(Level.SEVERE, "Multiverse-Core API is not available.", exception);
|
||||||
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getDataFolder().exists() && !getDataFolder().mkdirs()) {
|
||||||
|
getLogger().severe("Could not create plugin data folder.");
|
||||||
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File database = new File(getDataFolder(), "positions.db");
|
||||||
|
positionStore = new PositionStore(this, database.toPath());
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
getLogger().log(Level.SEVERE, "Could not open SQLite database.", exception);
|
||||||
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
worldPolicy = new WorldPolicy(multiverse, getConfig());
|
||||||
|
listener = new PlayerPositionListener(this, positionStore, worldPolicy);
|
||||||
|
getServer().getPluginManager().registerEvents(listener, this);
|
||||||
|
|
||||||
|
getLogger().info("Enabled with " + positionStore.cachedPositionCount()
|
||||||
|
+ " cached player positions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
if (listener != null && positionStore != null) {
|
||||||
|
getServer().getOnlinePlayers().forEach(listener::saveCurrent);
|
||||||
|
}
|
||||||
|
if (positionStore != null) {
|
||||||
|
positionStore.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(
|
||||||
|
@NotNull CommandSender sender,
|
||||||
|
@NotNull Command command,
|
||||||
|
@NotNull String label,
|
||||||
|
@NotNull String[] args
|
||||||
|
) {
|
||||||
|
if (args.length == 1 && args[0].equalsIgnoreCase("reload")) {
|
||||||
|
reloadConfig();
|
||||||
|
worldPolicy.reload(getConfig());
|
||||||
|
sender.sendMessage("§aMultiSpawn-Core: конфигурация перезагружена.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sender.sendMessage("§eИспользование: /" + label + " reload");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/main/java/ru/multispawn/core/PlayerPositionListener.java
Normal file
131
src/main/java/ru/multispawn/core/PlayerPositionListener.java
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package ru.multispawn.core;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.bukkit.event.player.PlayerPortalEvent;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
|
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||||
|
import org.mvplugins.multiverse.core.event.MVPlayerTouchedPortalEvent;
|
||||||
|
import ru.multispawn.core.model.StoredPosition;
|
||||||
|
import ru.multispawn.core.storage.PositionStore;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class PlayerPositionListener implements Listener {
|
||||||
|
private static final long PORTAL_TOUCH_WINDOW_NANOS = 1_000_000_000L;
|
||||||
|
|
||||||
|
private final MultiSpawnPlugin plugin;
|
||||||
|
private final PositionStore store;
|
||||||
|
private final WorldPolicy worldPolicy;
|
||||||
|
private final Map<UUID, Long> recentPortalTouches = new HashMap<>();
|
||||||
|
|
||||||
|
public PlayerPositionListener(MultiSpawnPlugin plugin, PositionStore store, WorldPolicy worldPolicy) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.store = store;
|
||||||
|
this.worldPolicy = worldPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the portal path exposed by the Multiverse-Core API itself.
|
||||||
|
*/
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onMultiversePortalTouch(MVPlayerTouchedPortalEvent event) {
|
||||||
|
recentPortalTouches.put(event.getPlayer().getUniqueId(), System.nanoTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onTeleport(PlayerTeleportEvent event) {
|
||||||
|
Location from = event.getFrom();
|
||||||
|
Location to = event.getTo();
|
||||||
|
if (to == null || from.getWorld() == null || to.getWorld() == null
|
||||||
|
|| from.getWorld().equals(to.getWorld())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPortalTransition(event)) {
|
||||||
|
recentPortalTouches.remove(event.getPlayer().getUniqueId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(event.getPlayer(), from);
|
||||||
|
|
||||||
|
if (!plugin.getConfig().getBoolean("restore.on-world-change", true)
|
||||||
|
|| !worldPolicy.isHandled(to.getWorld())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.find(event.getPlayer().getUniqueId(), to.getWorld().getName())
|
||||||
|
.map(StoredPosition::toLocation)
|
||||||
|
.ifPresent(event::setTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
|
if (!plugin.getConfig().getBoolean("restore.on-join", true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||||
|
if (!player.isOnline() || !worldPolicy.isHandled(player.getWorld())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.find(player.getUniqueId(), player.getWorld().getName())
|
||||||
|
.map(StoredPosition::toLocation)
|
||||||
|
.ifPresent(location ->
|
||||||
|
player.teleport(location, PlayerTeleportEvent.TeleportCause.PLUGIN));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onQuit(PlayerQuitEvent event) {
|
||||||
|
recentPortalTouches.remove(event.getPlayer().getUniqueId());
|
||||||
|
saveCurrent(event.getPlayer());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveCurrent(Player player) {
|
||||||
|
save(player, player.getLocation());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save(Player player, Location location) {
|
||||||
|
if (location.getWorld() != null && worldPolicy.isHandled(location.getWorld())) {
|
||||||
|
store.save(player.getUniqueId(), StoredPosition.from(location));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPortalTransition(PlayerTeleportEvent event) {
|
||||||
|
if (event instanceof PlayerPortalEvent) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerTeleportEvent.TeleportCause cause = event.getCause();
|
||||||
|
if (cause == PlayerTeleportEvent.TeleportCause.NETHER_PORTAL
|
||||||
|
&& plugin.getConfig().getBoolean("portals.ignore-nether-portals", true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (cause == PlayerTeleportEvent.TeleportCause.END_PORTAL
|
||||||
|
&& plugin.getConfig().getBoolean("portals.ignore-end-portals", true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (cause == PlayerTeleportEvent.TeleportCause.END_GATEWAY
|
||||||
|
&& plugin.getConfig().getBoolean("portals.ignore-end-gateways", true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Long touchedAt = recentPortalTouches.get(event.getPlayer().getUniqueId());
|
||||||
|
if (touchedAt == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (System.nanoTime() - touchedAt <= PORTAL_TOUCH_WINDOW_NANOS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
recentPortalTouches.remove(event.getPlayer().getUniqueId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/main/java/ru/multispawn/core/WorldPolicy.java
Normal file
56
src/main/java/ru/multispawn/core/WorldPolicy.java
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package ru.multispawn.core;
|
||||||
|
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.configuration.file.FileConfiguration;
|
||||||
|
import org.mvplugins.multiverse.core.MultiverseCoreApi;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides which worlds may have per-player return positions.
|
||||||
|
*/
|
||||||
|
public final class WorldPolicy {
|
||||||
|
private final MultiverseCoreApi multiverse;
|
||||||
|
private boolean includeDefaultWorlds;
|
||||||
|
private Set<String> defaultWorlds = Set.of();
|
||||||
|
private Set<String> enabledWorlds = Set.of();
|
||||||
|
private Set<String> disabledWorlds = Set.of();
|
||||||
|
|
||||||
|
public WorldPolicy(MultiverseCoreApi multiverse, FileConfiguration config) {
|
||||||
|
this.multiverse = multiverse;
|
||||||
|
reload(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reload(FileConfiguration config) {
|
||||||
|
includeDefaultWorlds = config.getBoolean("storage.include-default-worlds", false);
|
||||||
|
defaultWorlds = normalized(config.getStringList("storage.default-worlds"));
|
||||||
|
enabledWorlds = normalized(config.getStringList("storage.enabled-worlds"));
|
||||||
|
disabledWorlds = normalized(config.getStringList("storage.disabled-worlds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHandled(World world) {
|
||||||
|
String normalizedName = world.getName().toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
// The plugin is deliberately an addon: unmanaged Bukkit worlds are ignored.
|
||||||
|
if (!multiverse.getWorldManager().getWorld(world.getName()).isDefined()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (disabledWorlds.contains(normalizedName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!enabledWorlds.isEmpty() && !enabledWorlds.contains(normalizedName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return includeDefaultWorlds || !defaultWorlds.contains(normalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> normalized(Iterable<String> names) {
|
||||||
|
Set<String> result = new HashSet<>();
|
||||||
|
for (String name : names) {
|
||||||
|
result.add(name.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
return Set.copyOf(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/main/java/ru/multispawn/core/model/PositionKey.java
Normal file
6
src/main/java/ru/multispawn/core/model/PositionKey.java
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.multispawn.core.model;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record PositionKey(UUID playerId, String worldName) {
|
||||||
|
}
|
||||||
33
src/main/java/ru/multispawn/core/model/StoredPosition.java
Normal file
33
src/main/java/ru/multispawn/core/model/StoredPosition.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package ru.multispawn.core.model;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
|
||||||
|
public record StoredPosition(
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z,
|
||||||
|
float yaw,
|
||||||
|
float pitch
|
||||||
|
) {
|
||||||
|
public static StoredPosition from(Location location) {
|
||||||
|
if (location.getWorld() == null) {
|
||||||
|
throw new IllegalArgumentException("Location must have a world");
|
||||||
|
}
|
||||||
|
return new StoredPosition(
|
||||||
|
location.getWorld().getName(),
|
||||||
|
location.getX(),
|
||||||
|
location.getY(),
|
||||||
|
location.getZ(),
|
||||||
|
location.getYaw(),
|
||||||
|
location.getPitch()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Location toLocation() {
|
||||||
|
World world = Bukkit.getWorld(worldName);
|
||||||
|
return world == null ? null : new Location(world, x, y, z, yaw, pitch);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/main/java/ru/multispawn/core/storage/PositionRepository.java
Normal file
107
src/main/java/ru/multispawn/core/storage/PositionRepository.java
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package ru.multispawn.core.storage;
|
||||||
|
|
||||||
|
import ru.multispawn.core.model.PositionKey;
|
||||||
|
import ru.multispawn.core.model.StoredPosition;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class PositionRepository implements AutoCloseable {
|
||||||
|
private final Connection connection;
|
||||||
|
|
||||||
|
public PositionRepository(Path databasePath) throws SQLException {
|
||||||
|
connection = DriverManager.getConnection("jdbc:sqlite:" + databasePath.toAbsolutePath());
|
||||||
|
configure();
|
||||||
|
createSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configure() throws SQLException {
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("PRAGMA journal_mode=WAL");
|
||||||
|
statement.execute("PRAGMA synchronous=NORMAL");
|
||||||
|
statement.execute("PRAGMA busy_timeout=5000");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createSchema() throws SQLException {
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS player_positions (
|
||||||
|
player_uuid TEXT NOT NULL,
|
||||||
|
world_name TEXT NOT NULL,
|
||||||
|
x REAL NOT NULL,
|
||||||
|
y REAL NOT NULL,
|
||||||
|
z REAL NOT NULL,
|
||||||
|
yaw REAL NOT NULL,
|
||||||
|
pitch REAL NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (player_uuid, world_name)
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<PositionKey, StoredPosition> loadAll() throws SQLException {
|
||||||
|
Map<PositionKey, StoredPosition> positions = new HashMap<>();
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet result = statement.executeQuery("""
|
||||||
|
SELECT player_uuid, world_name, x, y, z, yaw, pitch
|
||||||
|
FROM player_positions
|
||||||
|
""")) {
|
||||||
|
while (result.next()) {
|
||||||
|
UUID playerId = UUID.fromString(result.getString("player_uuid"));
|
||||||
|
String world = result.getString("world_name");
|
||||||
|
positions.put(
|
||||||
|
new PositionKey(playerId, world),
|
||||||
|
new StoredPosition(
|
||||||
|
world,
|
||||||
|
result.getDouble("x"),
|
||||||
|
result.getDouble("y"),
|
||||||
|
result.getDouble("z"),
|
||||||
|
result.getFloat("yaw"),
|
||||||
|
result.getFloat("pitch")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void upsert(PositionKey key, StoredPosition position) throws SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
|
INSERT INTO player_positions
|
||||||
|
(player_uuid, world_name, x, y, z, yaw, pitch, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(player_uuid, world_name) DO UPDATE SET
|
||||||
|
x = excluded.x,
|
||||||
|
y = excluded.y,
|
||||||
|
z = excluded.z,
|
||||||
|
yaw = excluded.yaw,
|
||||||
|
pitch = excluded.pitch,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""")) {
|
||||||
|
statement.setString(1, key.playerId().toString());
|
||||||
|
statement.setString(2, key.worldName());
|
||||||
|
statement.setDouble(3, position.x());
|
||||||
|
statement.setDouble(4, position.y());
|
||||||
|
statement.setDouble(5, position.z());
|
||||||
|
statement.setFloat(6, position.yaw());
|
||||||
|
statement.setFloat(7, position.pitch());
|
||||||
|
statement.setLong(8, System.currentTimeMillis());
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws SQLException {
|
||||||
|
connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/main/java/ru/multispawn/core/storage/PositionStore.java
Normal file
73
src/main/java/ru/multispawn/core/storage/PositionStore.java
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package ru.multispawn.core.storage;
|
||||||
|
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import ru.multispawn.core.model.PositionKey;
|
||||||
|
import ru.multispawn.core.model.StoredPosition;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
public final class PositionStore implements AutoCloseable {
|
||||||
|
private final JavaPlugin plugin;
|
||||||
|
private final PositionRepository repository;
|
||||||
|
private final Map<PositionKey, StoredPosition> cache = new ConcurrentHashMap<>();
|
||||||
|
private final ExecutorService writer = Executors.newSingleThreadExecutor(runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "MultiSpawn-SQLite");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
|
||||||
|
public PositionStore(JavaPlugin plugin, Path databasePath) throws SQLException {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.repository = new PositionRepository(databasePath);
|
||||||
|
cache.putAll(repository.loadAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<StoredPosition> find(UUID playerId, String worldName) {
|
||||||
|
return Optional.ofNullable(cache.get(new PositionKey(playerId, worldName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(UUID playerId, StoredPosition position) {
|
||||||
|
PositionKey key = new PositionKey(playerId, position.worldName());
|
||||||
|
cache.put(key, position);
|
||||||
|
writer.execute(() -> {
|
||||||
|
try {
|
||||||
|
repository.upsert(key, position);
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
plugin.getLogger().log(Level.SEVERE,
|
||||||
|
"Could not save position for " + playerId + " in " + position.worldName(), exception);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public int cachedPositionCount() {
|
||||||
|
return cache.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
writer.shutdown();
|
||||||
|
try {
|
||||||
|
if (!writer.awaitTermination(10, TimeUnit.SECONDS)) {
|
||||||
|
plugin.getLogger().warning("Timed out while flushing SQLite writes.");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
plugin.getLogger().warning("Interrupted while flushing SQLite writes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
repository.close();
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
plugin.getLogger().log(Level.SEVERE, "Could not close SQLite database.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/main/resources/config.yml
Normal file
29
src/main/resources/config.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Only worlds imported into Multiverse-Core are handled.
|
||||||
|
storage:
|
||||||
|
# Standard server worlds are ignored by default.
|
||||||
|
# Enable this if their positions should also be stored.
|
||||||
|
include-default-worlds: false
|
||||||
|
|
||||||
|
# Names considered standard server worlds.
|
||||||
|
default-worlds:
|
||||||
|
- world
|
||||||
|
- world_nether
|
||||||
|
- world_the_end
|
||||||
|
|
||||||
|
# Empty means every Multiverse world (subject to the settings above).
|
||||||
|
# If populated, only these worlds are handled.
|
||||||
|
enabled-worlds: []
|
||||||
|
|
||||||
|
# These worlds are always ignored.
|
||||||
|
disabled-worlds: []
|
||||||
|
|
||||||
|
restore:
|
||||||
|
on-world-change: true
|
||||||
|
on-join: true
|
||||||
|
|
||||||
|
# Portal transitions never save or restore positions.
|
||||||
|
# MVPlayerTouchedPortalEvent is also observed for Multiverse portal handling.
|
||||||
|
portals:
|
||||||
|
ignore-nether-portals: true
|
||||||
|
ignore-end-portals: true
|
||||||
|
ignore-end-gateways: true
|
||||||
17
src/main/resources/plugin.yml
Normal file
17
src/main/resources/plugin.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: MultiSpawn-Core
|
||||||
|
version: '1.0.0'
|
||||||
|
main: ru.multispawn.core.MultiSpawnPlugin
|
||||||
|
api-version: '1.21.11'
|
||||||
|
author: MultiSpawn
|
||||||
|
description: Restores each player's last position in Multiverse worlds.
|
||||||
|
depend:
|
||||||
|
- Multiverse-Core
|
||||||
|
commands:
|
||||||
|
multispawn:
|
||||||
|
description: MultiSpawn-Core administration
|
||||||
|
usage: /multispawn reload
|
||||||
|
permission: multispawn.admin
|
||||||
|
permissions:
|
||||||
|
multispawn.admin:
|
||||||
|
description: Allows reloading MultiSpawn-Core
|
||||||
|
default: op
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package ru.multispawn.core.storage;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import ru.multispawn.core.model.PositionKey;
|
||||||
|
import ru.multispawn.core.model.StoredPosition;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class PositionRepositoryTest {
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertPersistsAndUpdatesPosition() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PositionKey key = new PositionKey(playerId, "survival");
|
||||||
|
|
||||||
|
try (PositionRepository repository = new PositionRepository(tempDir.resolve("test.db"))) {
|
||||||
|
repository.upsert(key, new StoredPosition("survival", 1, 2, 3, 4, 5));
|
||||||
|
repository.upsert(key, new StoredPosition("survival", 10, 20, 30, 40, 50));
|
||||||
|
|
||||||
|
Map<PositionKey, StoredPosition> loaded = repository.loadAll();
|
||||||
|
assertEquals(1, loaded.size());
|
||||||
|
assertEquals(
|
||||||
|
new StoredPosition("survival", 10, 20, 30, 40, 50),
|
||||||
|
loaded.get(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user