From 837b336e046bee27ca72f5b743abd57290fb9dab Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Fri, 31 Jul 2026 18:01:03 +0500 Subject: [PATCH] Intial commit --- .gitignore | 7 + README.md | 39 ++++++ pom.xml | 101 ++++++++++++++ .../ru/multispawn/core/MultiSpawnPlugin.java | 82 +++++++++++ .../core/PlayerPositionListener.java | 131 ++++++++++++++++++ .../java/ru/multispawn/core/WorldPolicy.java | 56 ++++++++ .../ru/multispawn/core/model/PositionKey.java | 6 + .../multispawn/core/model/StoredPosition.java | 33 +++++ .../core/storage/PositionRepository.java | 107 ++++++++++++++ .../core/storage/PositionStore.java | 73 ++++++++++ src/main/resources/config.yml | 29 ++++ src/main/resources/plugin.yml | 17 +++ .../core/storage/PositionRepositoryTest.java | 35 +++++ 13 files changed, 716 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/ru/multispawn/core/MultiSpawnPlugin.java create mode 100644 src/main/java/ru/multispawn/core/PlayerPositionListener.java create mode 100644 src/main/java/ru/multispawn/core/WorldPolicy.java create mode 100644 src/main/java/ru/multispawn/core/model/PositionKey.java create mode 100644 src/main/java/ru/multispawn/core/model/StoredPosition.java create mode 100644 src/main/java/ru/multispawn/core/storage/PositionRepository.java create mode 100644 src/main/java/ru/multispawn/core/storage/PositionStore.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml create mode 100644 src/test/java/ru/multispawn/core/storage/PositionRepositoryTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebf732d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +.m2/ +.idea/ +*.iml +*.db +*.db-shm +*.db-wal diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a5138e --- /dev/null +++ b/README.md @@ -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`. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..5f0515a --- /dev/null +++ b/pom.xml @@ -0,0 +1,101 @@ + + + 4.0.0 + + ru.multispawn + multispawn-core + 1.0.0 + MultiSpawn-Core + Per-world player return positions for Multiverse-Core. + + + 21 + UTF-8 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + onarandombox + https://repo.onarandombox.com/content/groups/public/ + + + + + + io.papermc.paper + paper-api + 1.21.11-R0.1-SNAPSHOT + provided + + + org.mvplugins.multiverse.core + multiverse-core + 5.7.1 + provided + + + org.xerial + sqlite-jdbc + 3.50.3.0 + + + org.junit.jupiter + junit-jupiter + 5.13.4 + test + + + + + MultiSpawn-Core-${project.version} + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + false + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + + diff --git a/src/main/java/ru/multispawn/core/MultiSpawnPlugin.java b/src/main/java/ru/multispawn/core/MultiSpawnPlugin.java new file mode 100644 index 0000000..9617fab --- /dev/null +++ b/src/main/java/ru/multispawn/core/MultiSpawnPlugin.java @@ -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; + } +} diff --git a/src/main/java/ru/multispawn/core/PlayerPositionListener.java b/src/main/java/ru/multispawn/core/PlayerPositionListener.java new file mode 100644 index 0000000..95458ca --- /dev/null +++ b/src/main/java/ru/multispawn/core/PlayerPositionListener.java @@ -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 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; + } +} diff --git a/src/main/java/ru/multispawn/core/WorldPolicy.java b/src/main/java/ru/multispawn/core/WorldPolicy.java new file mode 100644 index 0000000..66f90e8 --- /dev/null +++ b/src/main/java/ru/multispawn/core/WorldPolicy.java @@ -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 defaultWorlds = Set.of(); + private Set enabledWorlds = Set.of(); + private Set 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 normalized(Iterable names) { + Set result = new HashSet<>(); + for (String name : names) { + result.add(name.toLowerCase(Locale.ROOT)); + } + return Set.copyOf(result); + } +} diff --git a/src/main/java/ru/multispawn/core/model/PositionKey.java b/src/main/java/ru/multispawn/core/model/PositionKey.java new file mode 100644 index 0000000..dd0df70 --- /dev/null +++ b/src/main/java/ru/multispawn/core/model/PositionKey.java @@ -0,0 +1,6 @@ +package ru.multispawn.core.model; + +import java.util.UUID; + +public record PositionKey(UUID playerId, String worldName) { +} diff --git a/src/main/java/ru/multispawn/core/model/StoredPosition.java b/src/main/java/ru/multispawn/core/model/StoredPosition.java new file mode 100644 index 0000000..954707f --- /dev/null +++ b/src/main/java/ru/multispawn/core/model/StoredPosition.java @@ -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); + } +} diff --git a/src/main/java/ru/multispawn/core/storage/PositionRepository.java b/src/main/java/ru/multispawn/core/storage/PositionRepository.java new file mode 100644 index 0000000..7b82fa8 --- /dev/null +++ b/src/main/java/ru/multispawn/core/storage/PositionRepository.java @@ -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 loadAll() throws SQLException { + Map 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(); + } +} diff --git a/src/main/java/ru/multispawn/core/storage/PositionStore.java b/src/main/java/ru/multispawn/core/storage/PositionStore.java new file mode 100644 index 0000000..752705b --- /dev/null +++ b/src/main/java/ru/multispawn/core/storage/PositionStore.java @@ -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 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 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); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..85b0fa8 --- /dev/null +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..258942c --- /dev/null +++ b/src/main/resources/plugin.yml @@ -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 diff --git a/src/test/java/ru/multispawn/core/storage/PositionRepositoryTest.java b/src/test/java/ru/multispawn/core/storage/PositionRepositoryTest.java new file mode 100644 index 0000000..8da12f5 --- /dev/null +++ b/src/test/java/ru/multispawn/core/storage/PositionRepositoryTest.java @@ -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 loaded = repository.loadAll(); + assertEquals(1, loaded.size()); + assertEquals( + new StoredPosition("survival", 10, 20, 30, 40, 50), + loaded.get(key) + ); + } + } +}