Intial commit

This commit is contained in:
2026-07-31 18:01:03 +05:00
commit 837b336e04
13 changed files with 716 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
target/
.m2/
.idea/
*.iml
*.db
*.db-shm
*.db-wal

39
README.md Normal file
View 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
View 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>

View 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;
}
}

View 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;
}
}

View 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);
}
}

View File

@@ -0,0 +1,6 @@
package ru.multispawn.core.model;
import java.util.UUID;
public record PositionKey(UUID playerId, String worldName) {
}

View 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);
}
}

View 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();
}
}

View 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);
}
}
}

View 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

View 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

View File

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