Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.gradle.api.file.DuplicatesStrategy
import java.util.zip.ZipFile

plugins {
alias(libs.plugins.kotlin.jvm)
Expand All @@ -9,7 +10,7 @@ plugins {
}

group = "pl.syntaxdevteam"
version = "1.0.0-profile.1"
version = "1.0.0-profile.2"
description = "Lightweight and versatile player plot protection plugin"

repositories {
Expand Down Expand Up @@ -78,6 +79,7 @@ tasks {
}

val runtimeLibraryVersions = mapOf(
"kotlinVersion" to libs.versions.kotlin.get(),
"syntaxcoreVersion" to libs.versions.syntaxcore.get(),
"syntaxdbVersion" to libs.versions.syntaxdb.get(),
"messagehandlerVersion" to libs.versions.messagehandler.get(),
Expand All @@ -102,6 +104,8 @@ tasks.named<ShadowJar>("shadowJar") {
archiveClassifier.set("")
archiveVersion.set(project.version.toString())
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
// Kotlin belongs to Paper's library classloader together with SyntaxDB, not the plugin JAR.
dependencies { exclude(dependency("org.jetbrains.kotlin:.*:.*")) }
mergeServiceFiles()
filesMatching("META-INF/services/**") { duplicatesStrategy = DuplicatesStrategy.INCLUDE }
filesMatching("META-INF/*.kotlin_module") { duplicatesStrategy = DuplicatesStrategy.INCLUDE }
Expand Down Expand Up @@ -150,3 +154,27 @@ tasks.register<Jar>("foliaLiveTestJar") {
from(compileLiveTest.flatMap { it.destinationDirectory })
from("src/foliaLiveTest/resources")
}

val verifyRuntimePackaging = tasks.register("verifyRuntimePackaging") {
group = "verification"
description = "Prevents duplicate Kotlin runtimes across the plugin and Paper library classloaders."
val artifact = tasks.named<ShadowJar>("shadowJar").flatMap { it.archiveFile }
inputs.file(artifact)
dependsOn(tasks.named("shadowJar"))
doLast {
ZipFile(artifact.get().asFile).use { jar ->
check(jar.stream().noneMatch { it.name.startsWith("kotlin/") && it.name.endsWith(".class") }) {
"Kotlin runtime must be loaded by Paper, not bundled in the plugin JAR"
}
val manifest = jar.getInputStream(jar.getEntry("paper-libraries.yml")).bufferedReader().use { it.readText() }
check(manifest.contains("org.jetbrains.kotlin:kotlin-stdlib:${libs.versions.kotlin.get()}")) {
"Runtime manifest must explicitly load Kotlin matching the compiler"
}
val loader = jar.getInputStream(jar.getEntry("pl/syntaxdevteam/plotsx/loader/SyntaxLoader.class")).use { it.readBytes() }
check(!loader.toString(Charsets.ISO_8859_1).contains("kotlin/")) {
"Bootstrap loader must not depend on Kotlin before libraries are resolved"
}
}
}
}
tasks.named("check") { dependsOn(verifyRuntimePackaging) }
8 changes: 8 additions & 0 deletions docs/profile-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@
Read-only persisted snapshots for MiniPortalPlayerProfile 0.3.0. Existing snapshot constructors and existing API methods remain unchanged. New optional owner-list method provides composed detailed snapshots. No entity/chunk loading, teleport, database writes or simulation updates.

getOwnedPlotProfiles(UUID) uses the existing plot cache and owner filter. PlotProfileSnapshot wraps the original plot and nullable configured teleport block. TP X/Z are integer block coordinates (in-game destination uses +0.5). It is not a safe destination guarantee: the normal teleport flow revalidates blocks and can select a fallback. A missing configured point must not be replaced with a fabricated TP.

## Kotlin runtime packaging (1.0.0-profile.2)

The bootstrap loader is Java and uses only Paper-provided Java dependencies. `paper-libraries.yml` explicitly resolves `kotlin-stdlib` matching the Kotlin compiler. Kotlin runtime classes are excluded from the shaded plugin JAR, so PlotsX and SyntaxDB share the Kotlin classes in Paper's library classloader. Bundling Kotlin in the plugin while resolving SyntaxDB externally creates incompatible `kotlin.jvm.functions.Function1` identities and fails at `Database.connect` during enable.

`build` runs `verifyRuntimePackaging`: it rejects bundled Kotlin classes, a missing or unexpanded Kotlin runtime coordinate, and Kotlin references in the bootstrap loader. Unit tests cover repository routing and runtime manifest parsing. The disposable Paper acceptance plugin checks the actual `Function1` identity used by SyntaxDB and PlotsX before exercising SQLite and plot protection. Its seed data uses scoped SyntaxDB connections.

Update only the PlotsX JAR while the server is stopped, remove the previous duplicate, and restart fully. Keep the existing PlotsX configuration and database. No schema or player-data changes are part of this packaging fix.
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,36 @@ private Player player(UUID uuid, World world) {
};
});
}
@FunctionalInterface private interface SqlWork { void accept(Connection connection) throws Exception; }
/** Test-only access, preserving SyntaxDB's scoped connection lifecycle. */
private void withJdbc(DatabaseHandler handler, SqlWork work) throws Exception {
var method = DatabaseHandler.class.getDeclaredMethod("database");
method.setAccessible(true);
var database = (pl.syntaxdevteam.syntaxdb.Database) method.invoke(handler);
database.withConnection(connection -> {
try { work.accept(connection); }
catch (Exception failure) { throw new IllegalStateException("Acceptance fixture failed", failure); }
return Unit.INSTANCE;
});
}
private void runChecks() throws Exception {
PlotsX plugin = (PlotsX) getServer().getPluginManager().getPlugin("PlotsX");
check(plugin != null && plugin.isEnabled(), "plugin startup and migration");
PlotsXApi api = getServer().getServicesManager().load(PlotsXApi.class);
check(api != null && api.getApiVersion() == 2, "API v2 service");
var connect = pl.syntaxdevteam.syntaxdb.Database.Companion.getClass().getMethod("connect",
pl.syntaxdevteam.syntaxdb.config.DatabaseType.class, kotlin.jvm.functions.Function1.class);
check(connect.getParameterTypes()[1] == kotlin.jvm.functions.Function1.class,
"SyntaxDB connect and PlotsX share the same Kotlin Function1 class");
check(!kotlin.jvm.functions.Function1.class.getProtectionDomain().getCodeSource().getLocation().toString().contains("PlotsX-Paper"),
"Kotlin runtime is supplied by the library classloader");
boolean reuse = Boolean.getBoolean("plotsx.acceptance.reuse");
check(reuse ? api.getPlots().size() == 2 : api.getPlots().isEmpty(), reuse ? "persisted mixed plots survive mode restart" : "disposable empty database");
UUID owner = reuse ? api.getPlot(1).getOwner() : UUID.randomUUID();
World world = getServer().getWorlds().getFirst();
DatabaseHandler db = plugin.getDatabaseHandler();
// Seed both geometries using the migrated schema, before any actor events.
var connectionMethod = DatabaseHandler.class.getDeclaredMethod("getConnection");
connectionMethod.setAccessible(true);
if (!reuse) try (Connection c = (Connection) connectionMethod.invoke(db)) {
if (!reuse) withJdbc(db, c -> {
c.setAutoCommit(false);
try (PreparedStatement s = c.prepareStatement("INSERT INTO plots (plot_id,owner_uuid,x,z,y,radius,world,name,creation_time,geometry_type,geometry_revision) VALUES (?,?,?,?,?,?,?,?,?,?,0)")) {
for (int id = 1; id <= 2; id++) {
Expand All @@ -90,7 +106,7 @@ private void runChecks() throws Exception {
}
}
c.commit();
}
});
plugin.getProtectionCoordinator().recover(() -> { plugin.getCacheManager().reloadAllCachesSync(); return Unit.INSTANCE; });
check(api.getPlotAt(world.getName(),-16,-16).getRadius() == null, "chunk runtime has no artificial radius");
check(api.getPlotAt(world.getName(),-32,-16).getArea() == 768, "negative boundary and exact area");
Expand Down
92 changes: 92 additions & 0 deletions src/main/java/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package pl.syntaxdevteam.plotsx.loader;

import io.papermc.paper.plugin.loader.PluginClasspathBuilder;
import io.papermc.paper.plugin.loader.PluginLoader;
import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.eclipse.aether.graph.Dependency;
import org.eclipse.aether.repository.RemoteRepository;
import org.yaml.snakeyaml.Yaml;

import java.io.InputStream;
import java.util.*;

/** Java bootstrap: Kotlin is resolved before any Kotlin plugin code is loaded. */
@SuppressWarnings({"UnstableApiUsage", "unused"})
public final class SyntaxLoader implements PluginLoader {
@Override
public void classloader(PluginClasspathBuilder classpath) {
final LibraryConfig config;
try (InputStream input = SyntaxLoader.class.getClassLoader().getResourceAsStream("paper-libraries.yml")) {
if (input == null) throw new IllegalStateException("paper-libraries.yml not found in plugin JAR");
config = readLibraryConfig(input);
} catch (Exception failure) {
throw new IllegalStateException("Cannot configure PlotsX runtime libraries from paper-libraries.yml", failure);
}
Map<String, MavenLibraryResolver> resolvers = new LinkedHashMap<>();
for (LibrarySpec library : config.libraries()) {
String repositoryId = selectRepository(library, config);
MavenLibraryResolver resolver = resolvers.computeIfAbsent(repositoryId, key -> {
MavenLibraryResolver created = new MavenLibraryResolver();
repositoriesFor(key, config).forEach(created::addRepository);
return created;
});
resolver.addDependency(new Dependency(new DefaultArtifact(library.coordinate()), null));
}
resolvers.values().forEach(classpath::addLibrary);
}

static LibraryConfig readLibraryConfig(InputStream input) {
Object raw = new Yaml().load(input);
if (!(raw instanceof Map<?, ?> document)) throw new IllegalArgumentException("paper-libraries.yml must contain a mapping");
if (!(document.get("repositories") instanceof Map<?, ?> repositoryData)) throw new IllegalArgumentException("paper-libraries.yml must define repositories");
Map<String, RemoteRepository> repositories = new LinkedHashMap<>();
repositoryData.forEach((key, value) -> {
if (!(key instanceof String id) || id.isBlank() || !(value instanceof String configuredUrl) || configuredUrl.isBlank())
throw new IllegalArgumentException("Invalid repository entry: " + key);
String url = configuredUrl.trim().equals("MAVEN_CENTRAL_DEFAULT_MIRROR")
? MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR : configuredUrl.trim();
if (!url.startsWith("https://") && !url.startsWith("http://")) throw new IllegalArgumentException("Invalid repository URL for " + id);
repositories.put(id.trim(), new RemoteRepository.Builder(id.trim(), "default", url).build());
});
if (!repositories.containsKey("central")) throw new IllegalArgumentException("Missing central repository in paper-libraries.yml");
if (!(document.get("libraries") instanceof List<?> libraryData) || libraryData.isEmpty()) throw new IllegalArgumentException("No runtime libraries defined in paper-libraries.yml");
List<LibrarySpec> libraries = new ArrayList<>();
for (Object entry : libraryData) {
final LibrarySpec library;
if (entry instanceof String coordinate) library = new LibrarySpec(coordinate.trim(), null);
else if (entry instanceof Map<?, ?> map && map.get("coordinate") instanceof String coordinate) {
Object repository = map.get("repository");
if (repository != null && !(repository instanceof String)) throw new IllegalArgumentException("Invalid repository in library entry");
library = new LibrarySpec(coordinate.trim(), repository == null ? null : ((String) repository).trim());
} else throw new IllegalArgumentException("Invalid library entry");
if (library.coordinate().isEmpty() || library.coordinate().contains("${")) throw new IllegalArgumentException("Invalid or unexpanded library coordinate: " + library.coordinate());
new DefaultArtifact(library.coordinate());
libraries.add(library);
}
LibraryConfig config = new LibraryConfig(Collections.unmodifiableMap(repositories), List.copyOf(libraries));
libraries.forEach(library -> selectRepository(library, config));
return config;
}

static String selectRepository(LibrarySpec library, LibraryConfig config) {
String repositoryId = library.repository();
if (repositoryId == null) {
String group = library.coordinate().substring(0, library.coordinate().indexOf(':'));
repositoryId = group.equals("pl.syntaxdevteam") || group.startsWith("pl.syntaxdevteam.")
? library.coordinate().endsWith("-SNAPSHOT") ? "syntaxdevteam-snapshots" : "syntaxdevteam-releases"
: "central";
}
if (!config.repositories().containsKey(repositoryId)) throw new IllegalArgumentException("Unknown repository '" + repositoryId + "' for " + library.coordinate());
return repositoryId;
}

static List<RemoteRepository> repositoriesFor(String repositoryId, LibraryConfig config) {
RemoteRepository selected = config.repositories().get(repositoryId);
if (selected == null) throw new IllegalArgumentException("Unknown repository: " + repositoryId);
return repositoryId.equals("central") ? List.of(selected) : List.of(selected, config.repositories().get("central"));
}

record LibraryConfig(Map<String, RemoteRepository> repositories, List<LibrarySpec> libraries) {}
record LibrarySpec(String coordinate, String repository) {}
}
107 changes: 0 additions & 107 deletions src/main/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.kt

This file was deleted.

1 change: 1 addition & 0 deletions src/main/resources/paper-libraries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ repositories:
codemc-snapshots: "https://repo.codemc.io/repository/maven-snapshots/"

libraries:
- org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}
- pl.syntaxdevteam:syntaxcore:${syntaxcoreVersion}
- pl.syntaxdevteam:syntaxdb:${syntaxdbVersion}
- pl.syntaxdevteam:messageHandler-paper:${messagehandlerVersion}
Expand Down
Loading
Loading