diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml
new file mode 100644
index 0000000..fb82354
--- /dev/null
+++ b/.github/workflows/compatibility.yml
@@ -0,0 +1,60 @@
+name: Compatibility Matrix
+
+on:
+ push:
+ paths:
+ - "pom.xml"
+ - "headdb-api/**"
+ - "headdb-core/**"
+ - "headdb-legacy/**"
+ - ".github/workflows/compatibility.yml"
+ pull_request:
+ paths:
+ - "pom.xml"
+ - "headdb-api/**"
+ - "headdb-core/**"
+ - "headdb-legacy/**"
+ - ".github/workflows/compatibility.yml"
+ workflow_dispatch:
+
+jobs:
+ modern:
+ name: Modern Paper API ${{ matrix.paper }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - paper: 1.21-R0.1-SNAPSHOT
+ java: "21"
+ - paper: 26.2.build.112-stable
+ java: "25"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java }}
+ cache: maven
+ - run: mvn -B -ntp -pl headdb-core -am clean verify -Dpaper.version=${{ matrix.paper }}
+
+ legacy:
+ name: Legacy Spigot API ${{ matrix.spigot }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ spigot:
+ - 1.8.8-R0.1-SNAPSHOT
+ - 1.13.2-R0.1-SNAPSHOT
+ - 1.16.5-R0.1-SNAPSHOT
+ - 1.18.2-R0.1-SNAPSHOT
+ - 1.20.6-R0.1-SNAPSHOT
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+ - run: mvn -B -ntp -pl headdb-legacy -am clean verify -Dspigot.version=${{ matrix.spigot }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5d2f379..e55d48b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: "Tag to release (example: v6.0.2)"
+ description: "Tag to release (example: v6.0.3)"
required: true
type: string
@@ -108,6 +108,7 @@ jobs:
body_path: ${{ steps.metadata.outputs.changelog_file }}
files: |
headdb-core/target/HeadDB-*.jar
+ headdb-legacy/target/HeadDB-*-legacy.jar
fail_on_unmatched_files: true
- name: Create release with generated notes
@@ -119,4 +120,5 @@ jobs:
generate_release_notes: true
files: |
headdb-core/target/HeadDB-*.jar
+ headdb-legacy/target/HeadDB-*-legacy.jar
fail_on_unmatched_files: true
diff --git a/README.md b/README.md
index 68001ba..9bf5abe 100644
--- a/README.md
+++ b/README.md
@@ -35,11 +35,31 @@
## 🚀 Download & Installation
-HeadDB 6.0.2 targets Minecraft/Paper 26.2 and requires Java 25. Use the regular
-`HeadDB-6.0.2.jar` on Paper. The separately named `-Spigot.jar` artifact bundles
-the compatibility libraries needed by Spigot; Paper remains the recommended
-server platform. Folia is not currently supported because the bundled menu
-framework is not region-thread safe.
+HeadDB 6.0.3 is distributed as two server-specific jars. Install exactly one:
+
+| File | Server versions | Java | Purpose |
+|---|---|---|---|
+| `HeadDB-6.0.3.jar` | Paper 1.21.0 and newer | Java 21+ | The full modern plugin and the recommended download. |
+| `HeadDB-6.0.3-legacy.jar` | Bukkit-compatible 1.8.8-1.20.6 | Java 8 bytecode* | The isolated implementation for servers before 1.21. |
+
+\* Use the Java version required by the Minecraft server. The legacy plugin
+itself is Java 8-compatible, but later Minecraft releases require newer Java
+runtimes (for example, Java 17 or Java 21).
+
+Do not install both jars on the same server. The old `-Spigot.jar` classifier no
+longer exists: the modern artifact uses Paper APIs, while the legacy artifact is
+compiled against the 1.8.8 Spigot API, is compatibility-tested through the
+1.20.6 API, and does not contain `api-version` or a modern library-loader
+declaration. The modern artifact is compiled against Paper 1.21 and declares
+`api-version: 1.21`, allowing it to run on later Paper releases.
+
+The legacy jar includes the normal HeadDB browsing, search, favorites, local
+heads, custom-category, purchase, player-storage, API, localization, sound,
+update-checking, metrics, and database-refresh features. Its menus are implemented
+with Bukkit inventories so they work before Paper's modern menu APIs. Purchase
+amounts use inventory presets, and advanced MiniMessage effects are simplified
+on older clients. Folia is not currently supported because the bundled modern
+menu framework is not region-thread safe.
HeadDB checks GitHub Releases for updates on startup and every 24 hours by
default. Console notifications and player notifications can be configured under
@@ -246,6 +266,19 @@ you agree to the [Minecraft EULA](https://aka.ms/MinecraftEULA).
---
+## Building both release jars
+
+Run `mvn clean package` from the repository root. The release files are written
+to:
+
+- `headdb-core/target/HeadDB-6.0.3.jar`
+- `headdb-legacy/target/HeadDB-6.0.3-legacy.jar`
+
+The legacy module uses `--release 8`; the modern module uses `--release 21`.
+Maven may run on a newer JDK when building both artifacts together.
+
+---
+
## 🤗 Contributing
1. Fork the repository
diff --git a/changelog/6.0.3.md b/changelog/6.0.3.md
new file mode 100644
index 0000000..bd6b93b
--- /dev/null
+++ b/changelog/6.0.3.md
@@ -0,0 +1,34 @@
+# HeadDB 6.0.3 Changelog
+
+## Separate modern and legacy artifacts
+
+- `HeadDB-6.0.3.jar` is the Java 21 modern artifact for Paper 1.21.0 and newer.
+- `HeadDB-6.0.3-legacy.jar` is the Java 8-bytecode artifact for Bukkit-compatible
+ servers from 1.8.8 through 1.20.6.
+- Legacy material and player-profile operations are selected at runtime across
+ the pre- and post-flattening server APIs.
+- The previous `-Spigot.jar` classifier has been removed.
+- The public `headdb-api` module now targets Java 8 so integrations can use the
+ same service interfaces with either server artifact.
+
+## Legacy feature coverage
+
+The legacy module includes asynchronous database loading and fallback sources,
+indexed and structured searches, public API service registration, legacy
+textured-skull creation, browsing and purchase menus, favorites, local heads,
+custom categories, persistent player settings, Vault economy integration,
+configurable messages and sounds, update notifications, metrics, scheduled
+database refreshes, and the `info`, `search`, `give`, `open`, and `sounds`
+commands. It uses the same configuration and data schema as the modern jar so
+servers can switch artifacts without rebuilding their HeadDB data.
+
+Legacy menus use Bukkit inventories rather than the modern Paper menu framework.
+Interactive purchase quantities are provided as inventory presets, and advanced
+MiniMessage effects are simplified to formatting supported by older clients.
+
+Runtime smoke tests passed on every historical Paper release published across
+the supported range: 1.8.8, 1.9.4, 1.10.2, 1.11.2, 1.12-1.12.2,
+1.13-1.13.2, 1.14-1.14.4, 1.15-1.15.2, 1.16.1-1.16.5,
+1.17-1.17.1, 1.18-1.18.2, 1.19-1.19.4, and the published Paper releases
+from 1.20 through 1.20.6. Each test reached server-ready state, enabled HeadDB,
+and loaded the complete 86,248-head database without a HeadDB startup error.
diff --git a/headdb-api/pom.xml b/headdb-api/pom.xml
index 0082315..ff71c84 100644
--- a/headdb-api/pom.xml
+++ b/headdb-api/pom.xml
@@ -6,19 +6,19 @@
com.bitworksmc
HeadDB
- 6.0.2
+ 6.0.3
headdb-api
- 25
+ 8
- papermc
- https://repo.papermc.io/repository/maven-public/
+ spigotmc-snapshots
+ https://hub.spigotmc.org/nexus/content/repositories/snapshots/
@@ -43,10 +43,16 @@
provided
- io.papermc.paper
- paper-api
- ${paper.version}
+ org.spigotmc
+ spigot-api
+ ${spigot.version}
provided
+
+
+ net.md-5
+ bungeecord-chat
+
+
diff --git a/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/HeadAPI.java b/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/HeadAPI.java
index e39ec11..f045956 100644
--- a/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/HeadAPI.java
+++ b/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/HeadAPI.java
@@ -15,7 +15,7 @@
*
* @deprecated Use {@link com.bitworksmc.headdb.api.HeadAPI} instead.
*/
-@Deprecated(forRemoval = true, since = "6.0.0")
+@Deprecated
public interface HeadAPI {
/**
diff --git a/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/HeadDatabase.java b/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/HeadDatabase.java
index 6a698db..e16cf3e 100644
--- a/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/HeadDatabase.java
+++ b/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/HeadDatabase.java
@@ -8,7 +8,7 @@
/**
* @deprecated Use {@link com.bitworksmc.headdb.api.HeadDatabase} instead.
*/
-@Deprecated(forRemoval = true, since = "6.0.0")
+@Deprecated
public interface HeadDatabase {
CompletableFuture> update();
diff --git a/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/model/Head.java b/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/model/Head.java
index 15e5661..939fcf5 100644
--- a/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/model/Head.java
+++ b/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/model/Head.java
@@ -7,7 +7,7 @@
/**
* @deprecated Use {@link com.bitworksmc.headdb.api.model.Head} instead.
*/
-@Deprecated(forRemoval = true, since = "6.0.0")
+@Deprecated
public interface Head {
int getId();
diff --git a/headdb-core/pom.xml b/headdb-core/pom.xml
index ddaaea8..2600b66 100644
--- a/headdb-core/pom.xml
+++ b/headdb-core/pom.xml
@@ -6,16 +6,16 @@
com.bitworksmc
HeadDB
- 6.0.2
+ 6.0.3
headdb-core
Head Database
- 6.0.2
+ 6.0.3
- 25
+ 21
@@ -46,7 +46,7 @@
com.bitworksmc
headdb-api
- 6.0.2
+ 6.0.3
@@ -113,7 +113,7 @@
provided
-
+
net.kyori
adventure-api
@@ -167,14 +167,14 @@
3.5.4
-
+
org.apache.maven.plugins
maven-shade-plugin
3.6.2
-
+
shade-paper
package
@@ -228,57 +228,6 @@
-
-
- shade-spigot
- package
- shade
-
-
- true
- spigot
- HeadDB-${project.version}-Spigot
-
-
-
-
- net.kyori.adventure
- com.bitworksmc.libs.adventure
-
-
- com.github.thesilentpro.grim
- com.bitworksmc.libs.grim
-
-
- com.github.thesilentpro.localization
- com.bitworksmc.libs.localization
-
-
- com.github.thesilentpro.inputs
- com.bitworksmc.libs.inputs
-
-
-
-
-
- com.github.thesilentpro.localization:localization-paper
-
-
- com/github/thesilentpro/localization/paper/PaperLocalization*.class
-
-
-
-
-
-
- com.bitworksmc.headdb.core.HeadDB
-
-
-
- true
-
-
-
@@ -339,6 +288,8 @@
-Xms${dev.server.xms}
-Xmx${dev.server.xmx}
+
+ -Djoml.nounsafe=true
-jar
paper.jar
--nogui
diff --git a/headdb-core/src/main/resources/plugin.yml b/headdb-core/src/main/resources/plugin.yml
index 18eb20f..d0bcf77 100644
--- a/headdb-core/src/main/resources/plugin.yml
+++ b/headdb-core/src/main/resources/plugin.yml
@@ -8,7 +8,7 @@ softdepend: [
#"PlaceholderAPI",
"Vault"
]
-api-version: '26.2'
+api-version: '1.21'
libraries:
- "org.xerial:sqlite-jdbc:${sqlite.version}"
diff --git a/headdb-legacy/pom.xml b/headdb-legacy/pom.xml
new file mode 100644
index 0000000..f707d4f
--- /dev/null
+++ b/headdb-legacy/pom.xml
@@ -0,0 +1,136 @@
+
+
+ 4.0.0
+
+
+ com.bitworksmc
+ HeadDB
+ 6.0.3
+
+
+ headdb-legacy
+ HeadDB Legacy
+ HeadDB for Bukkit-compatible servers from 1.8 through 1.20
+
+
+ 8
+
+
+
+
+ spigotmc-snapshots
+ https://hub.spigotmc.org/nexus/content/repositories/snapshots/
+
+
+
+
+
+ com.bitworksmc
+ headdb-api
+ ${project.version}
+
+
+ org.spigotmc
+ spigot-api
+ ${spigot.version}
+ provided
+
+
+ net.md-5
+ bungeecord-chat
+
+
+
+
+ com.google.code.gson
+ gson
+ 2.8.9
+
+
+ org.bstats
+ bstats-bukkit
+ 3.1.0
+
+
+ org.xerial
+ sqlite-jdbc
+ 3.36.0.3
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+
+ HeadDB-${project.version}-legacy
+
+
+ src/main/resources
+ true
+
+
+ ${maven.multiModuleProjectDirectory}/headdb-core/src/main/resources
+ false
+
+ categories.yml
+ config.yml
+ sounds.yml
+ messages/**
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 8
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.6.2
+
+
+ package
+ shade
+
+ false
+ HeadDB-${project.version}-legacy
+ false
+
+
+ com.google.gson
+ com.bitworksmc.headdb.legacy.libs.gson
+
+
+ org.bstats
+ com.bitworksmc.headdb.legacy.libs.bstats
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+ module-info.class
+ META-INF/versions/**
+
+
+
+
+
+
+
+
+
+
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyApiAdapter.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyApiAdapter.java
new file mode 100644
index 0000000..8a94b58
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyApiAdapter.java
@@ -0,0 +1,57 @@
+package com.bitworksmc.headdb.legacy;
+
+import org.bukkit.inventory.ItemStack;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+
+@SuppressWarnings("deprecation")
+final class LegacyApiAdapter implements com.github.thesilentpro.headdb.api.HeadAPI {
+ private final com.bitworksmc.headdb.api.HeadAPI delegate;
+
+ LegacyApiAdapter(com.bitworksmc.headdb.api.HeadAPI delegate) { this.delegate = delegate; }
+ @Override public void awaitReady() { delegate.awaitReady(); }
+ @Override public boolean isReady() { return delegate.isReady(); }
+ @Override public CompletableFuture> onReady() {
+ return delegate.onReady().thenApply(LegacyApiAdapter::heads);
+ }
+ @Override public CompletableFuture> searchByName(String name, boolean lenient) {
+ return delegate.searchByName(name, lenient).thenApply(LegacyApiAdapter::heads);
+ }
+ @Override public CompletableFuture> findByName(String name, boolean lenient) {
+ return delegate.findByName(name, lenient).thenApply(LegacyApiAdapter::head);
+ }
+ @Override public CompletableFuture> findById(int id) {
+ return delegate.findById(id).thenApply(LegacyApiAdapter::head);
+ }
+ @Override public CompletableFuture> findByTexture(String texture) {
+ return delegate.findByTexture(texture).thenApply(LegacyApiAdapter::head);
+ }
+ @Override public CompletableFuture> findByCategory(String category) {
+ return delegate.findByCategory(category).thenApply(LegacyApiAdapter::heads);
+ }
+ @Override public CompletableFuture> findByTags(String... tags) {
+ return delegate.findByTags(tags).thenApply(LegacyApiAdapter::heads);
+ }
+ @Override public CompletableFuture> getHeads() {
+ return delegate.getHeads().thenApply(LegacyApiAdapter::heads);
+ }
+ @Override public List computeLocalHeads() { return delegate.computeLocalHeads(); }
+ @Override public Optional computeLocalHead(UUID uniqueId) { return delegate.computeLocalHead(uniqueId); }
+ @Override public List findKnownCategories() { return delegate.findKnownCategories(); }
+ @Override public ExecutorService getExecutor() { return delegate.getExecutor(); }
+
+ private static List heads(
+ List extends com.github.thesilentpro.headdb.api.model.Head> values) {
+ return new ArrayList(values);
+ }
+ private static Optional head(
+ Optional extends com.github.thesilentpro.headdb.api.model.Head> value) {
+ return value.isPresent() ? Optional.of(value.get())
+ : Optional.empty();
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyDatabase.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyDatabase.java
new file mode 100644
index 0000000..1eb4566
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyDatabase.java
@@ -0,0 +1,252 @@
+package com.bitworksmc.headdb.legacy;
+
+import com.bitworksmc.headdb.api.HeadDatabase;
+import com.bitworksmc.headdb.api.model.Head;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.Executor;
+
+final class LegacyDatabase implements HeadDatabase {
+ private static final java.lang.reflect.Type HEAD_LIST =
+ new TypeToken>() { }.getType();
+
+ private final List sourceUrls;
+ private final Executor executor;
+ private final Object updateLock = new Object();
+ private volatile Snapshot snapshot;
+ private volatile CompletableFuture> updateFuture;
+
+ LegacyDatabase(String sourceUrl, Executor executor) {
+ this(Collections.singletonList(sourceUrl), executor);
+ }
+
+ LegacyDatabase(List sourceUrls, Executor executor) {
+ this.sourceUrls = new ArrayList();
+ if (sourceUrls != null) for (String source : sourceUrls) {
+ if (source != null && !source.trim().isEmpty() && !this.sourceUrls.contains(source.trim())) {
+ this.sourceUrls.add(source.trim());
+ }
+ }
+ if (this.sourceUrls.isEmpty()) throw new IllegalArgumentException("At least one database URL is required");
+ this.executor = executor;
+ }
+
+ @Override
+ public CompletableFuture> update() {
+ synchronized (updateLock) {
+ if (updateFuture != null && !updateFuture.isDone()) {
+ return updateFuture;
+ }
+ updateFuture = CompletableFuture.supplyAsync(() -> load(), executor);
+ return updateFuture;
+ }
+ }
+
+ private List load() {
+ RuntimeException last = null;
+ for (String sourceUrl : sourceUrls) {
+ try { return load(sourceUrl); }
+ catch (RuntimeException exception) { last = exception; }
+ }
+ throw new CompletionException("Failed to update HeadDB from every configured source", last);
+ }
+
+ private List load(String sourceUrl) {
+ HttpURLConnection connection = null;
+ try {
+ connection = (HttpURLConnection) new URL(sourceUrl).openConnection();
+ connection.setRequestProperty("Accept", "application/json");
+ connection.setRequestProperty("User-Agent", "HeadDB-Legacy");
+ connection.setConnectTimeout(10000);
+ connection.setReadTimeout(30000);
+ connection.setInstanceFollowRedirects(true);
+
+ int status = connection.getResponseCode();
+ if (status != HttpURLConnection.HTTP_OK) {
+ throw new IOException("HTTP response code " + status);
+ }
+
+ List loaded;
+ InputStream stream = connection.getInputStream();
+ try {
+ BufferedReader reader = new BufferedReader(
+ new InputStreamReader(stream, StandardCharsets.UTF_8), 8192);
+ loaded = new Gson().fromJson(reader, HEAD_LIST);
+ } finally {
+ stream.close();
+ }
+ if (loaded == null || loaded.isEmpty()) {
+ throw new IOException("Database payload was empty");
+ }
+
+ Snapshot next = Snapshot.create(loaded);
+ snapshot = next;
+ return next.heads;
+ } catch (IOException | RuntimeException exception) {
+ throw new CompletionException("Failed to update HeadDB from " + sourceUrl, exception);
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ }
+
+ @Override
+ public boolean awaitReady() {
+ CompletableFuture> future = updateFuture;
+ if (future == null) {
+ return false;
+ }
+ try {
+ future.join();
+ return true;
+ } catch (CompletionException exception) {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean isReady() {
+ return snapshot != null;
+ }
+
+ @Override
+ public CompletableFuture> onReady() {
+ Snapshot current = snapshot;
+ if (current != null) {
+ return CompletableFuture.completedFuture(current.heads);
+ }
+ CompletableFuture> future = updateFuture;
+ return future == null ? update() : future;
+ }
+
+ @Override
+ public List getHeads() {
+ Snapshot current = snapshot;
+ return current == null ? null : current.heads;
+ }
+
+ @Override
+ public List getByCategory(String category) {
+ Snapshot current = snapshot;
+ if (current == null || category == null) {
+ return Collections.emptyList();
+ }
+ List result = current.byCategory.get(category.toLowerCase(Locale.ROOT));
+ return result == null ? Collections.emptyList() : result;
+ }
+
+ @Override
+ public List getByTags(String... tags) {
+ Snapshot current = snapshot;
+ if (current == null || tags == null || tags.length == 0) {
+ return Collections.emptyList();
+ }
+ Set result = new LinkedHashSet();
+ for (String tag : tags) {
+ if (tag == null) {
+ continue;
+ }
+ List matches = current.byTag.get(tag.toLowerCase(Locale.ROOT));
+ if (matches != null) {
+ result.addAll(matches);
+ }
+ }
+ return immutableList(result);
+ }
+
+ @Override
+ public Head getById(int id) {
+ Snapshot current = snapshot;
+ return current == null ? null : current.byId.get(id);
+ }
+
+ @Override
+ public Head getByTexture(String texture) {
+ Snapshot current = snapshot;
+ return current == null || texture == null ? null : current.byTexture.get(texture);
+ }
+
+ private static List immutableList(Collection extends Head> source) {
+ return Collections.unmodifiableList(new ArrayList(source));
+ }
+
+ private static final class Snapshot {
+ private final List heads;
+ private final Map byId;
+ private final Map byTexture;
+ private final Map> byCategory;
+ private final Map> byTag;
+
+ private Snapshot(List heads, Map byId, Map byTexture,
+ Map> byCategory, Map> byTag) {
+ this.heads = heads;
+ this.byId = byId;
+ this.byTexture = byTexture;
+ this.byCategory = byCategory;
+ this.byTag = byTag;
+ }
+
+ private static Snapshot create(List loaded) {
+ List heads = new ArrayList(loaded.size());
+ Map byId = new LinkedHashMap();
+ Map byTexture = new LinkedHashMap();
+ Map> byCategory = new LinkedHashMap>();
+ Map> byTag = new LinkedHashMap>();
+
+ for (LegacyHead head : loaded) {
+ head.validate();
+ if (byId.put(head.getId(), head) != null) {
+ throw new IllegalArgumentException("Duplicate head ID " + head.getId());
+ }
+ byTexture.put(head.getTexture(), head);
+ heads.add(head);
+ add(byCategory, head.getCategory().toLowerCase(Locale.ROOT), head);
+ for (String tag : head.getTags()) {
+ if (tag != null) {
+ add(byTag, tag.toLowerCase(Locale.ROOT), head);
+ }
+ }
+ }
+ freezeValues(byCategory);
+ freezeValues(byTag);
+ return new Snapshot(immutableList(heads), Collections.unmodifiableMap(byId),
+ Collections.unmodifiableMap(byTexture), Collections.unmodifiableMap(byCategory),
+ Collections.unmodifiableMap(byTag));
+ }
+
+ private static void add(Map> index, String key, Head value) {
+ List values = index.get(key);
+ if (values == null) {
+ values = new ArrayList();
+ index.put(key, values);
+ }
+ values.add(value);
+ }
+
+ private static void freezeValues(Map> index) {
+ for (Map.Entry> entry : index.entrySet()) {
+ entry.setValue(Collections.unmodifiableList(entry.getValue()));
+ }
+ }
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyEconomy.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyEconomy.java
new file mode 100644
index 0000000..6fe101a
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyEconomy.java
@@ -0,0 +1,66 @@
+package com.bitworksmc.headdb.legacy;
+
+import com.bitworksmc.headdb.api.model.Head;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.configuration.file.FileConfiguration;
+
+import java.lang.reflect.Method;
+
+final class LegacyEconomy {
+ private final Object provider;
+ private final FileConfiguration config;
+
+ LegacyEconomy(FileConfiguration config) {
+ this.config = config;
+ this.provider = findProvider(config.getString("economy.provider", "NONE"));
+ }
+
+ boolean isEnabled() { return provider != null; }
+
+ double price(Head head) {
+ String headPath = "economy.cost.head." + head.getId();
+ if (config.contains(headPath)) return Math.max(0D, config.getDouble(headPath));
+ return Math.max(0D, config.getDouble("economy.cost.category." + head.getCategory().toLowerCase(), 0D));
+ }
+
+ boolean purchase(OfflinePlayer player, double amount) {
+ if (provider == null || amount <= 0D) return true;
+ try {
+ Object has = invokePlayer("has", player, amount);
+ if (!(has instanceof Boolean) || !((Boolean) has)) return false;
+ Object response = invokePlayer("withdrawPlayer", player, amount);
+ if (response == null) return false;
+ try {
+ Object success = response.getClass().getMethod("transactionSuccess").invoke(response);
+ return success instanceof Boolean && (Boolean) success;
+ } catch (NoSuchMethodException ignored) {
+ return true;
+ }
+ } catch (ReflectiveOperationException exception) {
+ return false;
+ }
+ }
+
+ private Object invokePlayer(String name, OfflinePlayer player, double amount) throws ReflectiveOperationException {
+ for (Method method : provider.getClass().getMethods()) {
+ Class>[] types = method.getParameterTypes();
+ if (!method.getName().equals(name) || types.length != 2 || types[1] != double.class) continue;
+ if (types[0].isInstance(player)) return method.invoke(provider, player, amount);
+ if (types[0] == String.class) return method.invoke(provider, player.getName(), amount);
+ }
+ throw new NoSuchMethodException(name);
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static Object findProvider(String configured) {
+ if (configured == null || !configured.equalsIgnoreCase("VAULT")) return null;
+ try {
+ Class economy = Class.forName("net.milkbowl.vault.economy.Economy");
+ org.bukkit.plugin.RegisteredServiceProvider registration = Bukkit.getServicesManager().getRegistration(economy);
+ return registration == null ? null : registration.getProvider();
+ } catch (ClassNotFoundException ignored) {
+ return null;
+ }
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHead.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHead.java
new file mode 100644
index 0000000..5e9e8ed
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHead.java
@@ -0,0 +1,71 @@
+package com.bitworksmc.headdb.legacy;
+
+import com.bitworksmc.headdb.api.model.Head;
+import org.bukkit.inventory.ItemStack;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+final class LegacyHead implements Head {
+ private int id;
+ private String name;
+ private String texture;
+ private String category;
+ private List tags;
+ private transient volatile ItemStack cachedItem;
+
+ @Override
+ public int getId() {
+ return id;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String getTexture() {
+ return texture;
+ }
+
+ @Override
+ public String getCategory() {
+ return category;
+ }
+
+ @Override
+ public List getTags() {
+ if (tags == null) {
+ return Collections.emptyList();
+ }
+ return Collections.unmodifiableList(tags);
+ }
+
+ void validate() {
+ if (id < 0 || isEmpty(name) || isEmpty(texture) || isEmpty(category)) {
+ throw new IllegalArgumentException("Invalid head entry with ID " + id);
+ }
+ tags = tags == null ? Collections.emptyList() : new ArrayList(tags);
+ }
+
+ @Override
+ public ItemStack getItem() {
+ ItemStack item = cachedItem;
+ if (item == null) {
+ synchronized (this) {
+ item = cachedItem;
+ if (item == null) {
+ item = LegacyItemFactory.createHead(this);
+ cachedItem = item;
+ }
+ }
+ }
+ return item.clone();
+ }
+
+ private static boolean isEmpty(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadAPI.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadAPI.java
new file mode 100644
index 0000000..5b60b7a
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadAPI.java
@@ -0,0 +1,127 @@
+package com.bitworksmc.headdb.legacy;
+
+import com.bitworksmc.headdb.api.HeadAPI;
+import com.bitworksmc.headdb.api.HeadDatabase;
+import com.bitworksmc.headdb.api.model.Head;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.inventory.ItemStack;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+
+final class LegacyHeadAPI implements HeadAPI {
+ private final ExecutorService executor;
+ private final HeadDatabase database;
+
+ LegacyHeadAPI(ExecutorService executor, HeadDatabase database) {
+ this.executor = executor;
+ this.database = database;
+ }
+
+ @Override public void awaitReady() { database.awaitReady(); }
+ @Override public boolean isReady() { return database.isReady(); }
+ @Override public CompletableFuture> onReady() { return database.onReady(); }
+
+ @Override
+ public CompletableFuture> searchByName(final String name, final boolean lenient) {
+ Objects.requireNonNull(name, "name");
+ return CompletableFuture.supplyAsync(() -> {
+ List result = new ArrayList();
+ String query = name.toLowerCase(Locale.ROOT);
+ for (Head head : heads()) {
+ String candidate = head.getName().toLowerCase(Locale.ROOT);
+ if (lenient ? candidate.contains(query) : candidate.equals(query)) {
+ result.add(head);
+ }
+ }
+ return Collections.unmodifiableList(result);
+ }, executor);
+ }
+
+ @Override
+ public CompletableFuture> findByName(final String name, final boolean lenient) {
+ return searchByName(name, lenient).thenApply(matches ->
+ matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0)));
+ }
+
+ @Override
+ public CompletableFuture> findById(final int id) {
+ return CompletableFuture.supplyAsync(() -> Optional.ofNullable(database.getById(id)), executor);
+ }
+
+ @Override
+ public CompletableFuture> findByTexture(final String texture) {
+ return CompletableFuture.supplyAsync(() -> Optional.ofNullable(database.getByTexture(texture)), executor);
+ }
+
+ @Override
+ public CompletableFuture> findByCategory(final String category) {
+ return CompletableFuture.supplyAsync(() -> database.getByCategory(category), executor);
+ }
+
+ @Override
+ public CompletableFuture> findByTags(final String... tags) {
+ return CompletableFuture.supplyAsync(() -> database.getByTags(tags), executor);
+ }
+
+ @Override
+ public CompletableFuture> getHeads() {
+ return CompletableFuture.supplyAsync(() -> heads(), executor);
+ }
+
+ @Override
+ public List computeLocalHeads() {
+ List result = new ArrayList();
+ for (OfflinePlayer player : Bukkit.getOfflinePlayers()) {
+ ItemStack item = localHead(player);
+ if (item != null) {
+ result.add(item);
+ }
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ @Override
+ public Optional computeLocalHead(UUID uniqueId) {
+ Objects.requireNonNull(uniqueId, "uniqueId");
+ return Optional.ofNullable(localHead(Bukkit.getOfflinePlayer(uniqueId)));
+ }
+
+ private ItemStack localHead(OfflinePlayer player) {
+ if (player == null || player.getName() == null) {
+ return null;
+ }
+ ItemStack item = LegacyItemFactory.newPlayerHead();
+ org.bukkit.inventory.meta.SkullMeta meta = (org.bukkit.inventory.meta.SkullMeta) item.getItemMeta();
+ meta.setOwner(player.getName());
+ meta.setDisplayName(player.getName());
+ item.setItemMeta(meta);
+ return item;
+ }
+
+ @Override
+ public List findKnownCategories() {
+ Set categories = new LinkedHashSet();
+ for (Head head : heads()) {
+ categories.add(head.getCategory());
+ }
+ return Collections.unmodifiableList(new ArrayList(categories));
+ }
+
+ @Override public ExecutorService getExecutor() { return executor; }
+
+ private List heads() {
+ List result = database.getHeads();
+ return result == null ? Collections.emptyList() : result;
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java
new file mode 100644
index 0000000..6461a68
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java
@@ -0,0 +1,403 @@
+package com.bitworksmc.headdb.legacy;
+
+import com.bitworksmc.headdb.api.HeadAPI;
+import com.bitworksmc.headdb.api.model.Head;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.PluginCommand;
+import org.bukkit.command.TabCompleter;
+import org.bukkit.entity.Player;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.plugin.ServicePriority;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitRunnable;
+import org.bstats.bukkit.Metrics;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+
+public final class LegacyHeadDB extends JavaPlugin implements CommandExecutor, TabCompleter {
+ private ExecutorService executor;
+ private LegacyDatabase database;
+ private LegacyHeadAPI api;
+ private LegacyPlayerStorage playerStorage;
+ private LegacyMessages messages;
+ private LegacySounds sounds;
+ private LegacyMenuManager menus;
+ private LegacyEconomy economy;
+ private LegacyApiAdapter deprecatedApi;
+
+ @Override
+ public void onEnable() {
+ saveDefaultConfig();
+ ensureResource("categories.yml");
+ ensureResource("sounds.yml");
+ ensureResource("messages/en.yml");
+ playerStorage = new LegacyPlayerStorage(getDataFolder(), getLogger());
+ playerStorage.load();
+ messages = new LegacyMessages(getDataFolder());
+ sounds = new LegacySounds(getDataFolder(), playerStorage);
+
+ int threads = Math.max(1, getConfig().getInt("database.threads",
+ getConfig().getInt("database-threads", 2)));
+ executor = Executors.newFixedThreadPool(threads, new ThreadFactory() {
+ private int sequence;
+
+ @Override
+ public Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, "HeadDB Legacy Worker-" + (++sequence));
+ thread.setDaemon(true);
+ return thread;
+ }
+ });
+
+ String databaseUrl = getConfig().getString("database.sourceUrl",
+ getConfig().getString("database-url"));
+ List databaseUrls = new ArrayList();
+ databaseUrls.add(databaseUrl);
+ databaseUrls.addAll(getConfig().getStringList("database.fallbackSourceUrls"));
+ database = new LegacyDatabase(databaseUrls, executor);
+ api = new LegacyHeadAPI(executor, database);
+ economy = new LegacyEconomy(getConfig());
+ LegacyItemFactory.configure(getConfig(), economy.isEnabled());
+ menus = new LegacyMenuManager(this, database, api, playerStorage, messages, sounds, economy);
+ getServer().getServicesManager().register(HeadAPI.class, api, this, ServicePriority.Normal);
+ deprecatedApi = new LegacyApiAdapter(api);
+ getServer().getServicesManager().register(com.github.thesilentpro.headdb.api.HeadAPI.class,
+ deprecatedApi, this, ServicePriority.Normal);
+
+ PluginCommand command = getCommand("headdb");
+ if (command == null) {
+ throw new IllegalStateException("Missing headdb command in plugin.yml");
+ }
+ command.setExecutor(this);
+ command.setTabCompleter(this);
+
+ database.update().whenComplete((heads, failure) -> Bukkit.getScheduler().runTask(this, () -> {
+ if (!isEnabled()) {
+ return;
+ }
+ if (failure != null) {
+ getLogger().severe("Could not load the head database: " + rootMessage(failure));
+ } else {
+ getLogger().info("Loaded " + heads.size() + " heads.");
+ if (getConfig().getBoolean("preloadHeads", false)) preload(heads);
+ }
+ }));
+ long saveSeconds = Math.max(60L, getConfig().getLong("storage.player.saveInterval", 1800L));
+ Bukkit.getScheduler().runTaskTimerAsynchronously(this, playerStorage::save,
+ saveSeconds * 20L, saveSeconds * 20L);
+ if (getConfig().getBoolean("updater", true)) {
+ Bukkit.getScheduler().runTaskTimerAsynchronously(this, database::update,
+ 24L * 60L * 60L * 20L, 24L * 60L * 60L * 20L);
+ }
+ if (getConfig().getBoolean("updateChecker.enabled", true)) new LegacyUpdateChecker(this);
+ new Metrics(this, 30043);
+ getLogger().info("Enabled the Minecraft 1.8.8-1.20.6 legacy implementation.");
+ }
+
+ private void ensureResource(String path) {
+ if (!new java.io.File(getDataFolder(), path).isFile()) saveResource(path, false);
+ }
+
+ private void preload(final List heads) {
+ new BukkitRunnable() {
+ private int index;
+ @Override public void run() {
+ int end = Math.min(index + 100, heads.size());
+ while (index < end) heads.get(index++).getItem();
+ if (index >= heads.size() || !isEnabled()) cancel();
+ }
+ }.runTaskTimer(this, 1L, 1L);
+ }
+
+ @Override
+ public void onDisable() {
+ if (api != null) {
+ getServer().getServicesManager().unregister(HeadAPI.class, api);
+ }
+ if (deprecatedApi != null) {
+ getServer().getServicesManager().unregister(com.github.thesilentpro.headdb.api.HeadAPI.class, deprecatedApi);
+ }
+ if (playerStorage != null) {
+ playerStorage.save();
+ }
+ if (executor != null) {
+ executor.shutdownNow();
+ }
+ }
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+ if (args.length == 0) {
+ return open(sender, args);
+ }
+ if (args[0].equalsIgnoreCase("info")) {
+ if (!sender.hasPermission("headdb.command.info")) {
+ return denied(sender);
+ }
+ sender.sendMessage(ChatColor.GOLD + "HeadDB " + getDescription().getVersion()
+ + ChatColor.GRAY + " (legacy)");
+ sender.sendMessage(ChatColor.GRAY + "Server: " + Bukkit.getName() + " " + Bukkit.getBukkitVersion());
+ sender.sendMessage(ChatColor.GRAY + "Database: "
+ + (database.isReady() ? database.getHeads().size() + " heads" : "loading"));
+ return true;
+ }
+
+ if (args[0].equalsIgnoreCase("search")) {
+ return search(sender, args);
+ }
+ if (args[0].equalsIgnoreCase("give")) {
+ return give(sender, args);
+ }
+ if (args[0].equalsIgnoreCase("sounds")) {
+ return toggleSounds(sender);
+ }
+ if (args[0].equalsIgnoreCase("open")) {
+ return open(sender, args);
+ }
+
+ sender.sendMessage(ChatColor.RED + "Usage: /" + label + " [info|search |give [player]]");
+ return true;
+ }
+
+ private boolean open(CommandSender sender, String[] args) {
+ if (!(sender instanceof Player)) {
+ sender.sendMessage(messages.get("noConsole", "Only players can use this command."));
+ return true;
+ }
+ if (!sender.hasPermission("headdb.command.open")) return denied(sender);
+ if (!database.isReady()) {
+ sender.sendMessage(messages.get("databaseLoading", "The head database is still loading."));
+ return true;
+ }
+ Player player = (Player) sender;
+ if (args.length >= 2) menus.openCategory(player, join(args, 1), 0);
+ else menus.openMain(player);
+ return true;
+ }
+
+ private boolean toggleSounds(CommandSender sender) {
+ if (!(sender instanceof Player)) {
+ sender.sendMessage(messages.get("noConsole", "Only players can use this command."));
+ return true;
+ }
+ if (!sender.hasPermission("headdb.command.sounds")) return denied(sender);
+ Player player = (Player) sender;
+ LegacyPlayerData data = playerStorage.get(player.getUniqueId());
+ data.setSoundsEnabled(!data.isSoundsEnabled());
+ player.sendMessage(messages.get(data.isSoundsEnabled() ? "command.sounds.enabled" : "command.sounds.disabled",
+ data.isSoundsEnabled() ? "HeadDB interface sounds enabled." : "HeadDB interface sounds disabled."));
+ if (data.isSoundsEnabled()) sounds.play(player, "success");
+ return true;
+ }
+
+ private boolean search(final CommandSender sender, String[] args) {
+ if (!sender.hasPermission("headdb.command.search")) {
+ return denied(sender);
+ }
+ if (args.length < 2) {
+ sender.sendMessage(ChatColor.RED + "Usage: /hdb search ");
+ return true;
+ }
+ if (!database.isReady()) {
+ sender.sendMessage(ChatColor.YELLOW + "The head database is still loading.");
+ return true;
+ }
+
+ final String query = join(args, 1);
+ java.util.concurrent.CompletableFuture.supplyAsync(() -> filterSearch(args, sender), executor).whenComplete((matches, failure) ->
+ Bukkit.getScheduler().runTask(this, () -> {
+ if (failure != null) {
+ sender.sendMessage(ChatColor.RED + "Search failed: " + rootMessage(failure));
+ return;
+ }
+ int limit = Math.max(1, getConfig().getInt("search-limit", 20));
+ if (sender instanceof Player) {
+ menus.openSearch((Player) sender, query, matches);
+ sender.sendMessage(messages.get("command.search.found", "Found {amount} heads!",
+ "amount", String.valueOf(matches.size())));
+ return;
+ }
+ sender.sendMessage(ChatColor.GOLD + "HeadDB matches for '" + query + "':");
+ for (int i = 0; i < Math.min(limit, matches.size()); i++) {
+ Head head = matches.get(i);
+ sender.sendMessage(ChatColor.YELLOW + "#" + head.getId() + ChatColor.GRAY + " "
+ + head.getName() + " (" + head.getCategory() + ")");
+ }
+ if (matches.isEmpty()) {
+ sender.sendMessage(ChatColor.GRAY + "No matching heads.");
+ } else if (matches.size() > limit) {
+ sender.sendMessage(ChatColor.GRAY + "...and " + (matches.size() - limit) + " more.");
+ }
+ }));
+ return true;
+ }
+
+ private List filterSearch(String[] args, CommandSender sender) {
+ String category = null;
+ Set tags = new HashSet();
+ Set ids = new HashSet();
+ List names = new ArrayList();
+ boolean any = false;
+ for (int i = 1; i < args.length; i++) {
+ String token = args[i];
+ String lower = token.toLowerCase(Locale.ROOT);
+ if (lower.equals("--any")) any = true;
+ else if (lower.startsWith("category:")) category = lower.substring(9);
+ else if (lower.startsWith("tags:")) {
+ for (String tag : lower.substring(5).split(",")) if (!tag.trim().isEmpty()) tags.add(tag.trim());
+ } else if (lower.startsWith("ids:")) {
+ for (String id : lower.substring(4).split(",")) {
+ try { ids.add(Integer.parseInt(id.trim())); } catch (NumberFormatException ignored) { }
+ }
+ } else names.add(token);
+ }
+ String name = join(names.toArray(new String[names.size()]), 0).toLowerCase(Locale.ROOT);
+ List result = new ArrayList();
+ List all = database.getHeads();
+ if (all == null) return result;
+ for (Head head : all) {
+ boolean nameMatch = !name.isEmpty() && head.getName().toLowerCase(Locale.ROOT).contains(name);
+ boolean categoryMatch = category != null && head.getCategory().equalsIgnoreCase(category);
+ boolean idMatch = !ids.isEmpty() && ids.contains(head.getId());
+ Set headTags = new HashSet();
+ for (String tag : head.getTags()) headTags.add(tag.toLowerCase(Locale.ROOT));
+ boolean tagAny = !tags.isEmpty() && !Collections.disjoint(tags, headTags);
+ boolean tagAll = headTags.containsAll(tags);
+ boolean matches;
+ if (any) matches = nameMatch || categoryMatch || idMatch || tagAny;
+ else matches = (name.isEmpty() || nameMatch) && (category == null || categoryMatch)
+ && (ids.isEmpty() || idMatch) && (tags.isEmpty() || tagAll);
+ String permission = "headdb.category." + head.getCategory().toLowerCase(Locale.ROOT)
+ .replaceAll("[^a-z0-9._-]+", "_").replaceAll("^_+|_+$", "");
+ if (matches && (sender.hasPermission("headdb.category.*") || sender.hasPermission(permission))) result.add(head);
+ }
+ return result;
+ }
+
+ private boolean give(CommandSender sender, String[] args) {
+ if (!sender.hasPermission("headdb.command.give")) {
+ return denied(sender);
+ }
+ if (args.length < 4) {
+ sender.sendMessage(ChatColor.RED + "Usage: /hdb give ");
+ return true;
+ }
+ Player target = Bukkit.getPlayer(args[1]);
+ if (target == null) {
+ sender.sendMessage(messages.get("invalidTarget", "Could not find player: {target}", "target", args[1]));
+ return true;
+ }
+ int amount;
+ try {
+ amount = Integer.parseInt(args[2]);
+ } catch (NumberFormatException exception) {
+ sender.sendMessage(messages.get("invalidNumber", "Invalid number: {number}", "number", args[2]));
+ return true;
+ }
+ int maximum = Math.max(1, getConfig().getInt("maxBuyAmount", 2304));
+ if (amount < 1 || amount > maximum) {
+ sender.sendMessage(messages.get("command.give.invalidAmount", "Amount must be between 1 and {max}",
+ "max", String.valueOf(maximum)));
+ return true;
+ }
+ String identifier = join(args, 3);
+ Head head = null;
+ if (identifier.toLowerCase(Locale.ROOT).startsWith("id:")) {
+ try { head = database.getById(Integer.parseInt(identifier.substring(3))); } catch (NumberFormatException ignored) { }
+ }
+ if (head == null) head = database.getByTexture(identifier);
+ if (head == null) {
+ List all = database.getHeads();
+ if (all != null) for (Head candidate : all) {
+ if (candidate.getName().equalsIgnoreCase(identifier)) { head = candidate; break; }
+ }
+ }
+ if (head == null) {
+ sender.sendMessage(messages.get("command.give.invalidId", "Unknown head: {id}", "id", identifier));
+ return true;
+ }
+ int remaining = amount;
+ while (remaining > 0) {
+ ItemStack item = head.getItem();
+ item = LegacyItemFactory.prepareForGive(item);
+ int stackSize = Math.min(remaining, item.getMaxStackSize());
+ item.setAmount(stackSize);
+ java.util.Map leftovers = target.getInventory().addItem(item);
+ for (ItemStack leftover : leftovers.values()) target.getWorld().dropItemNaturally(target.getLocation(), leftover);
+ remaining -= stackSize;
+ }
+ sender.sendMessage(messages.get("command.give.success", "Gave {amount}x {name} to {target}",
+ "amount", String.valueOf(amount), "name", head.getName(), "target", target.getName()));
+ if (sender instanceof Player) sounds.play((Player) sender, "success");
+ return true;
+ }
+
+ private boolean denied(CommandSender sender) {
+ sender.sendMessage(ChatColor.RED + "You do not have permission to use that command.");
+ return true;
+ }
+
+ @Override
+ public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
+ if (args.length == 1) {
+ return prefix(Arrays.asList("open", "info", "search", "give", "sounds"), args[0]);
+ }
+ if (args.length == 2 && args[0].equalsIgnoreCase("give")) {
+ List players = new ArrayList();
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ players.add(player.getName());
+ }
+ return prefix(players, args[1]);
+ }
+ if (args.length == 3 && args[0].equalsIgnoreCase("give")) {
+ return prefix(Arrays.asList("1", "32", "64"), args[2]);
+ }
+ if (args.length >= 2 && args[0].equalsIgnoreCase("search")) {
+ return prefix(Arrays.asList("tags:", "category:", "ids:", "--any"), args[args.length - 1]);
+ }
+ return Collections.emptyList();
+ }
+
+ private static List prefix(List values, String input) {
+ String normalized = input.toLowerCase(Locale.ROOT);
+ List result = new ArrayList();
+ for (String value : values) {
+ if (value.toLowerCase(Locale.ROOT).startsWith(normalized)) {
+ result.add(value);
+ }
+ }
+ return result;
+ }
+
+ private static String join(String[] values, int start) {
+ StringBuilder result = new StringBuilder();
+ for (int i = start; i < values.length; i++) {
+ if (result.length() > 0) {
+ result.append(' ');
+ }
+ result.append(values[i]);
+ }
+ return result.toString();
+ }
+
+ private static String rootMessage(Throwable failure) {
+ Throwable current = failure;
+ while (current.getCause() != null) {
+ current = current.getCause();
+ }
+ return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage();
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java
new file mode 100644
index 0000000..68f8e0e
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java
@@ -0,0 +1,194 @@
+package com.bitworksmc.headdb.legacy;
+
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.bukkit.inventory.meta.SkullMeta;
+import org.bukkit.configuration.file.FileConfiguration;
+
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+final class LegacyItemFactory {
+ private static volatile FileConfiguration configuration;
+ private static volatile boolean economyEnabled;
+ private LegacyItemFactory() {
+ }
+
+ static void configure(FileConfiguration config, boolean economy) {
+ configuration = config;
+ economyEnabled = economy;
+ }
+
+ static ItemStack createHead(LegacyHead head) {
+ ItemStack item = newPlayerHead();
+ SkullMeta meta = (SkullMeta) item.getItemMeta();
+ FileConfiguration config = configuration;
+ String mode = economyEnabled ? "economy" : "default";
+ String name = config == null ? "{name}" : config.getString("head.name." + mode, "{name}");
+ meta.setDisplayName(LegacyMessages.color(replace(name, head)));
+
+ List configuredLore = config == null ? Collections.emptyList()
+ : config.getStringList("head.lore." + mode);
+ List lore = new ArrayList();
+ if (configuredLore.isEmpty()) {
+ lore.add(ChatColor.DARK_GRAY + "ID: " + head.getId());
+ lore.add(ChatColor.GRAY + head.getCategory());
+ } else {
+ for (String line : configuredLore) lore.add(LegacyMessages.color(replace(line, head)));
+ }
+ meta.setLore(lore);
+
+ if (!applyBukkitProfile(meta, head.getTexture()) && !applyAuthlibProfile(meta, head.getTexture())) {
+ meta.setOwner("MHF_Question");
+ }
+ item.setItemMeta(meta);
+ return item;
+ }
+
+ private static String replace(String value, LegacyHead head) {
+ FileConfiguration config = configuration;
+ double price = 0D;
+ if (config != null) {
+ String path = "economy.cost.head." + head.getId();
+ price = config.contains(path) ? config.getDouble(path)
+ : config.getDouble("economy.cost.category." + head.getCategory().toLowerCase(), 0D);
+ }
+ return value.replace("{id}", String.valueOf(head.getId()))
+ .replace("{name}", head.getName()).replace("{category}", head.getCategory())
+ .replace("{tags}", String.join(",", head.getTags())).replace("{cost}", String.valueOf(price));
+ }
+
+ static ItemStack createTextureHead(String textureHash) {
+ ItemStack item = newPlayerHead();
+ SkullMeta meta = (SkullMeta) item.getItemMeta();
+ if (!applyBukkitProfile(meta, textureHash) && !applyAuthlibProfile(meta, textureHash)) {
+ meta.setOwner("MHF_Question");
+ }
+ item.setItemMeta(meta);
+ return item;
+ }
+
+ static ItemStack prepareForGive(ItemStack source) {
+ ItemStack item = source.clone();
+ FileConfiguration config = configuration;
+ if (config == null || config.getIntegerList("head.omit").isEmpty()) return item;
+ ItemMeta meta = item.getItemMeta();
+ if (meta == null || !meta.hasLore()) return item;
+ List omitted = config.getIntegerList("head.omit");
+ List lore = new ArrayList();
+ List original = meta.getLore();
+ for (int i = 0; i < original.size(); i++) if (!omitted.contains(i + 1)) lore.add(original.get(i));
+ meta.setLore(lore);
+ item.setItemMeta(meta);
+ return item;
+ }
+
+ /**
+ * PLAYER_HEAD was introduced by the 1.13 material flattening. Resolve both
+ * names dynamically so this class never links a missing enum constant.
+ */
+ static ItemStack newPlayerHead() {
+ Material modern = Material.matchMaterial("PLAYER_HEAD");
+ if (modern != null) {
+ return new ItemStack(modern, 1);
+ }
+
+ Material legacy = Material.matchMaterial("SKULL_ITEM");
+ if (legacy == null) {
+ throw new IllegalStateException("This server does not expose a player-head material");
+ }
+ return new ItemStack(legacy, 1, (short) 3);
+ }
+
+ /**
+ * Uses the Bukkit profile API introduced after the legacy GameProfile era.
+ * All types are resolved reflectively so the class still loads on 1.8.
+ */
+ private static boolean applyBukkitProfile(SkullMeta meta, String textureHash) {
+ try {
+ Class> profileClass = Class.forName("org.bukkit.profile.PlayerProfile");
+ Method createProfile = org.bukkit.Bukkit.class.getMethod(
+ "createPlayerProfile", UUID.class, String.class);
+ Object profile = createProfile.invoke(null, UUID.randomUUID(), null);
+ Object textures = profileClass.getMethod("getTextures").invoke(profile);
+ textures.getClass().getMethod("setSkin", URL.class).invoke(
+ textures, new URL("https://textures.minecraft.net/texture/" + textureHash));
+ invokeCompatible(profile, "setTextures", textures);
+
+ if (!invokeCompatible(meta, "setOwnerProfile", profile)) {
+ // Paper exposed this spelling before Bukkit standardized setOwnerProfile.
+ if (!invokeCompatible(meta, "setPlayerProfile", profile)) {
+ return false;
+ }
+ }
+ return true;
+ } catch (ReflectiveOperationException | IOException | LinkageError ignored) {
+ return false;
+ }
+ }
+
+ /**
+ * CraftBukkit 1.8-1.20 stores Mojang's GameProfile directly in SkullMeta.
+ */
+ private static boolean applyAuthlibProfile(SkullMeta meta, String textureHash) {
+ try {
+ Class> profileClass = Class.forName("com.mojang.authlib.GameProfile");
+ Constructor> profileConstructor = profileClass.getConstructor(UUID.class, String.class);
+ Object profile = profileConstructor.newInstance(UUID.randomUUID(), null);
+
+ String json = "{\"textures\":{\"SKIN\":{\"url\":\"https://textures.minecraft.net/texture/"
+ + textureHash + "\"}}}";
+ String encoded = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));
+
+ Class> propertyClass = Class.forName("com.mojang.authlib.properties.Property");
+ Object property = propertyClass.getConstructor(String.class, String.class)
+ .newInstance("textures", encoded);
+ Object properties = profileClass.getMethod("getProperties").invoke(profile);
+ Method put = properties.getClass().getMethod("put", Object.class, Object.class);
+ put.invoke(properties, "textures", property);
+
+ Field profileField = findField(meta.getClass(), "profile");
+ profileField.setAccessible(true);
+ profileField.set(meta, profile);
+ return true;
+ } catch (ReflectiveOperationException | LinkageError ignored) {
+ return false;
+ }
+ }
+
+ private static boolean invokeCompatible(Object target, String name, Object argument)
+ throws ReflectiveOperationException {
+ for (Method method : target.getClass().getMethods()) {
+ if (method.getName().equals(name)
+ && method.getParameterTypes().length == 1
+ && method.getParameterTypes()[0].isInstance(argument)) {
+ method.invoke(target, argument);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Field findField(Class> type, String name) throws NoSuchFieldException {
+ Class> current = type;
+ while (current != null) {
+ try {
+ return current.getDeclaredField(name);
+ } catch (NoSuchFieldException ignored) {
+ current = current.getSuperclass();
+ }
+ }
+ throw new NoSuchFieldException(name);
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMenuManager.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMenuManager.java
new file mode 100644
index 0000000..33e460f
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMenuManager.java
@@ -0,0 +1,391 @@
+package com.bitworksmc.headdb.legacy;
+
+import com.bitworksmc.headdb.api.model.Head;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.InventoryHolder;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+
+final class LegacyMenuManager implements Listener {
+ private static final int PAGE_SIZE = 45;
+ private final LegacyHeadDB plugin;
+ private final LegacyDatabase database;
+ private final LegacyHeadAPI api;
+ private final LegacyPlayerStorage storage;
+ private final LegacyMessages messages;
+ private final LegacySounds sounds;
+ private final LegacyEconomy economy;
+
+ LegacyMenuManager(LegacyHeadDB plugin, LegacyDatabase database, LegacyHeadAPI api,
+ LegacyPlayerStorage storage, LegacyMessages messages, LegacySounds sounds,
+ LegacyEconomy economy) {
+ this.plugin = plugin;
+ this.database = database;
+ this.api = api;
+ this.storage = storage;
+ this.messages = messages;
+ this.sounds = sounds;
+ this.economy = economy;
+ plugin.getServer().getPluginManager().registerEvents(this, plugin);
+ }
+
+ void openMain(Player player) {
+ MenuHolder holder = new MenuHolder("main", "", 0, Collections.emptyList());
+ Inventory inventory = create(holder, 54, messages.get("menu.main.name", "HeadDB"));
+ int slot = 0;
+ for (String category : api.findKnownCategories()) {
+ if (slot >= 45) break;
+ String permission = "headdb.category." + normalize(category);
+ if (!player.hasPermission("headdb.category.*") && !player.hasPermission(permission)) continue;
+ inventory.setItem(slot, item(material("CHEST", "CHEST"), ChatColor.GOLD + category));
+ holder.actions.put(slot++, "category:" + category);
+ }
+ inventory.setItem(45, item(material("NETHER_STAR", "GOLD_INGOT"), ChatColor.YELLOW + "Favorites"));
+ holder.actions.put(45, "favorites");
+ inventory.setItem(46, named(LegacyItemFactory.newPlayerHead(), ChatColor.AQUA + "Local Heads"));
+ holder.actions.put(46, "local");
+ inventory.setItem(47, item(material("BOOKSHELF", "BOOKSHELF"), ChatColor.LIGHT_PURPLE + "More Categories"));
+ holder.actions.put(47, "custom");
+ player.openInventory(inventory);
+ sounds.play(player, "menu.open");
+ }
+
+ void openCategory(Player player, String category, int page) {
+ if (!allowed(player, category)) { deny(player); return; }
+ openHeads(player, "category", category, database.getByCategory(category), page,
+ ChatColor.RED + "HeadDB " + ChatColor.GRAY + "» " + ChatColor.GOLD + category);
+ }
+
+ void openSearch(Player player, String query, List heads) {
+ openHeads(player, "search", query, heads, 0,
+ ChatColor.RED + "HeadDB " + ChatColor.GRAY + "» " + ChatColor.GOLD + query);
+ }
+
+ void openFavorites(Player player, int page) {
+ if (!allowed(player, "favorites")) { deny(player); return; }
+ List heads = new ArrayList();
+ LegacyPlayerData data = storage.get(player.getUniqueId());
+ for (Integer id : data.getFavorites()) {
+ Head head = database.getById(id);
+ if (head != null) heads.add(head);
+ }
+ List locals = data.getLocalFavorites();
+ int total = heads.size() + locals.size();
+ int maxPage = maxPage(total);
+ page = Math.max(0, Math.min(page, maxPage));
+ MenuHolder holder = new MenuHolder("favorites", "", page, heads);
+ Inventory inventory = create(holder, 54, messages.get("menu.favorites.name", "HeadDB » Favorites"));
+ int start = page * PAGE_SIZE;
+ for (int index = start; index < Math.min(start + PAGE_SIZE, total); index++) {
+ int slot = index - start;
+ if (index < heads.size()) {
+ Head head = heads.get(index);
+ inventory.setItem(slot, head.getItem());
+ holder.actions.put(slot, "head:" + head.getId());
+ } else {
+ UUID id = locals.get(index - heads.size());
+ OfflinePlayer offline = Bukkit.getOfflinePlayer(id);
+ if (offline.getName() != null) {
+ inventory.setItem(slot, localHead(offline));
+ holder.actions.put(slot, "local-head:" + id);
+ }
+ }
+ }
+ addControls(inventory, holder, page, maxPage);
+ player.openInventory(inventory);
+ }
+
+ void openCustomCategories(Player player) {
+ if (!allowed(player, "custom")) { deny(player); return; }
+ MenuHolder holder = new MenuHolder("custom", "", 0, Collections.emptyList());
+ Inventory inventory = create(holder, 54, messages.get("menu.customCategories.name", "HeadDB » More Categories"));
+ YamlConfiguration config = YamlConfiguration.loadConfiguration(new File(plugin.getDataFolder(), "categories.yml"));
+ int slot = 0;
+ for (String key : config.getKeys(false)) {
+ if (slot >= 45 || !config.getBoolean(key + ".enabled", true)) continue;
+ String name = LegacyMessages.color(config.getString(key + ".icon.name", key));
+ String texture = config.getString(key + ".icon.head");
+ ItemStack icon = texture == null ? item(material("BOOKSHELF", "BOOKSHELF"), name)
+ : customTexture(texture, name);
+ inventory.setItem(slot, icon);
+ holder.actions.put(slot++, "custom-category:" + key);
+ }
+ addBack(inventory, holder);
+ player.openInventory(inventory);
+ }
+
+ private void openCustomCategory(Player player, String key, int page) {
+ if (!allowed(player, key)) { deny(player); return; }
+ YamlConfiguration config = YamlConfiguration.loadConfiguration(new File(plugin.getDataFolder(), "categories.yml"));
+ List heads = new ArrayList();
+ for (String texture : config.getStringList(key + ".heads")) {
+ Head head = database.getByTexture(texture);
+ if (head != null) heads.add(head);
+ }
+ String name = LegacyMessages.color(config.getString(key + ".icon.name", key));
+ openHeads(player, "custom-heads", key, heads, page, ChatColor.RED + "HeadDB » " + name);
+ }
+
+ private void openLocal(Player player, int page) {
+ if (!allowed(player, "local")) { deny(player); return; }
+ OfflinePlayer[] players = Bukkit.getOfflinePlayers();
+ int maxPage = maxPage(players.length);
+ page = Math.max(0, Math.min(page, maxPage));
+ MenuHolder holder = new MenuHolder("local", "", page, Collections.emptyList());
+ Inventory inventory = create(holder, 54, messages.get("menu.local.name", "HeadDB » Local Heads"));
+ int start = page * PAGE_SIZE;
+ for (int i = start; i < Math.min(start + PAGE_SIZE, players.length); i++) {
+ OfflinePlayer offline = players[i];
+ if (offline.getName() == null) continue;
+ ItemStack icon = localHead(offline);
+ inventory.setItem(i - start, icon);
+ holder.actions.put(i - start, "local-head:" + offline.getUniqueId());
+ }
+ addControls(inventory, holder, page, maxPage);
+ player.openInventory(inventory);
+ }
+
+ private void openHeads(Player player, String type, String key, List heads, int page, String title) {
+ int maxPage = maxPage(heads.size());
+ page = Math.max(0, Math.min(page, maxPage));
+ MenuHolder holder = new MenuHolder(type, key, page, heads);
+ Inventory inventory = create(holder, 54, title);
+ int start = page * PAGE_SIZE;
+ LegacyPlayerData data = storage.get(player.getUniqueId());
+ for (int i = start; i < Math.min(start + PAGE_SIZE, heads.size()); i++) {
+ Head head = heads.get(i);
+ ItemStack icon = head.getItem();
+ ItemMeta meta = icon.getItemMeta();
+ List lore = meta.hasLore() ? new ArrayList(meta.getLore()) : new ArrayList();
+ lore.add("");
+ lore.add(ChatColor.YELLOW + "Left click: Take");
+ lore.add((data.isFavorite(head.getId()) ? ChatColor.RED + "Right click: Unfavorite"
+ : ChatColor.GREEN + "Right click: Favorite"));
+ meta.setLore(lore);
+ icon.setItemMeta(meta);
+ inventory.setItem(i - start, icon);
+ holder.actions.put(i - start, "head:" + head.getId());
+ }
+ addControls(inventory, holder, page, maxPage);
+ player.openInventory(inventory);
+ }
+
+ @EventHandler
+ public void onClick(InventoryClickEvent event) {
+ Inventory top = event.getView().getTopInventory();
+ if (!(top.getHolder() instanceof MenuHolder) || event.getRawSlot() < 0
+ || event.getRawSlot() >= top.getSize()) return;
+ event.setCancelled(true);
+ if (!(event.getWhoClicked() instanceof Player)) return;
+ Player player = (Player) event.getWhoClicked();
+ MenuHolder holder = (MenuHolder) top.getHolder();
+ String action = holder.actions.get(event.getRawSlot());
+ if (action == null) return;
+
+ if (action.equals("main")) openMain(player);
+ else if (action.equals("back")) reopen(holder, player, holder.page - 1);
+ else if (action.equals("next")) reopen(holder, player, holder.page + 1);
+ else if (action.equals("favorites")) openFavorites(player, 0);
+ else if (action.equals("local")) openLocal(player, 0);
+ else if (action.equals("custom")) openCustomCategories(player);
+ else if (action.startsWith("category:")) openCategory(player, action.substring(9), 0);
+ else if (action.startsWith("custom-category:")) openCustomCategory(player, action.substring(16), 0);
+ else if (action.startsWith("head:")) clickHead(player, Integer.parseInt(action.substring(5)), event.isRightClick(), holder);
+ else if (action.startsWith("local-head:")) clickLocalHead(player, UUID.fromString(action.substring(11)), event.isRightClick(), holder);
+ else if (action.startsWith("purchase:")) purchase(player, action);
+ }
+
+ private void clickHead(Player player, int id, boolean favoriteClick, MenuHolder holder) {
+ Head head = database.getById(id);
+ if (head == null) return;
+ if (!allowed(player, head.getCategory())) { deny(player); return; }
+ if (favoriteClick) {
+ boolean added = storage.get(player.getUniqueId()).toggleFavorite(id);
+ player.sendMessage(ChatColor.GOLD + head.getName() + (added ? ChatColor.GREEN + " added to favorites."
+ : ChatColor.RED + " removed from favorites."));
+ sounds.play(player, added ? "favorite.add" : "favorite.remove");
+ reopen(holder, player, holder.page);
+ return;
+ }
+ if (economy.isEnabled()) openPurchase(player, head);
+ else {
+ give(player, head.getItem());
+ sounds.play(player, "head.take");
+ }
+ }
+
+ private void openPurchase(Player player, Head head) {
+ MenuHolder holder = new MenuHolder("purchase", String.valueOf(head.getId()), 0, Collections.emptyList());
+ Inventory inventory = create(holder, 27, messages.get("menu.purchase.name", "HeadDB » Purchase",
+ "name", head.getName()));
+ int[] amounts = {1, 8, 16, 32, 64};
+ int[] slots = {10, 11, 13, 15, 16};
+ double unit = economy.price(head);
+ for (int i = 0; i < amounts.length; i++) {
+ ItemStack icon = head.getItem();
+ icon.setAmount(Math.min(amounts[i], icon.getMaxStackSize()));
+ ItemMeta meta = icon.getItemMeta();
+ meta.setDisplayName(ChatColor.GREEN + String.valueOf(amounts[i]) + "x " + head.getName());
+ List lore = new ArrayList();
+ lore.add(ChatColor.GRAY + "Cost: " + ChatColor.GOLD + (unit * amounts[i]));
+ meta.setLore(lore);
+ icon.setItemMeta(meta);
+ inventory.setItem(slots[i], icon);
+ holder.actions.put(slots[i], "purchase:" + head.getId() + ":" + amounts[i]);
+ }
+ addBack(inventory, holder);
+ player.openInventory(inventory);
+ }
+
+ private void purchase(Player player, String action) {
+ String[] parts = action.split(":");
+ Head head = database.getById(Integer.parseInt(parts[1]));
+ int amount = Integer.parseInt(parts[2]);
+ if (head == null) return;
+ double total = economy.price(head) * amount;
+ if (!economy.purchase(player, total)) {
+ player.sendMessage(messages.get("purchase.invalidFunds", "You do not have enough money."));
+ sounds.play(player, "purchase.failed");
+ return;
+ }
+ int remaining = amount;
+ while (remaining > 0) {
+ ItemStack item = head.getItem();
+ int stack = Math.min(remaining, item.getMaxStackSize());
+ item.setAmount(stack);
+ give(player, item);
+ remaining -= stack;
+ }
+ player.closeInventory();
+ player.sendMessage(messages.get("purchase.success", "Bought {amount}x {name} for {cost}",
+ "amount", String.valueOf(amount), "name", head.getName(), "cost", String.valueOf(total)));
+ sounds.play(player, "purchase.completed");
+ }
+
+ private void clickLocalHead(Player player, UUID id, boolean favoriteClick, MenuHolder holder) {
+ OfflinePlayer offline = Bukkit.getOfflinePlayer(id);
+ if (favoriteClick) {
+ storage.get(player.getUniqueId()).toggleLocalFavorite(id);
+ reopen(holder, player, holder.page);
+ return;
+ }
+ give(player, localHead(offline));
+ sounds.play(player, "head.take");
+ }
+
+ private void reopen(MenuHolder holder, Player player, int page) {
+ if (holder.type.equals("category")) openCategory(player, holder.key, page);
+ else if (holder.type.equals("favorites")) openFavorites(player, page);
+ else if (holder.type.equals("local")) openLocal(player, page);
+ else if (holder.type.equals("custom-heads")) openCustomCategory(player, holder.key, page);
+ else if (holder.type.equals("search")) openHeads(player, "search", holder.key, holder.heads, page,
+ ChatColor.RED + "HeadDB » " + ChatColor.GOLD + holder.key);
+ else if (holder.type.equals("purchase")) openMain(player);
+ else openMain(player);
+ }
+
+ private void give(Player player, ItemStack item) {
+ item = LegacyItemFactory.prepareForGive(item);
+ Map leftovers = player.getInventory().addItem(item);
+ for (ItemStack leftover : leftovers.values()) player.getWorld().dropItemNaturally(player.getLocation(), leftover);
+ }
+
+ private void addControls(Inventory inventory, MenuHolder holder, int page, int maxPage) {
+ if (page > 0) {
+ inventory.setItem(45, item(material("ARROW", "ARROW"), ChatColor.GOLD + "Previous"));
+ holder.actions.put(45, "back");
+ }
+ inventory.setItem(49, item(material("PAPER", "PAPER"), ChatColor.GOLD + "Page " + (page + 1) + "/" + (maxPage + 1)));
+ holder.actions.put(49, "main");
+ if (page < maxPage) {
+ inventory.setItem(53, item(material("ARROW", "ARROW"), ChatColor.GOLD + "Next"));
+ holder.actions.put(53, "next");
+ }
+ }
+
+ private void addBack(Inventory inventory, MenuHolder holder) {
+ int slot = inventory.getSize() - 5;
+ inventory.setItem(slot, item(material("ARROW", "ARROW"), ChatColor.GOLD + "Back"));
+ holder.actions.put(slot, "main");
+ }
+
+ private Inventory create(MenuHolder holder, int size, String title) {
+ String safe = title == null ? "HeadDB" : title;
+ if (safe.length() > 32) safe = safe.substring(0, 32);
+ Inventory inventory = Bukkit.createInventory(holder, size, safe);
+ holder.inventory = inventory;
+ return inventory;
+ }
+
+ private ItemStack localHead(OfflinePlayer player) {
+ ItemStack item = LegacyItemFactory.newPlayerHead();
+ org.bukkit.inventory.meta.SkullMeta meta = (org.bukkit.inventory.meta.SkullMeta) item.getItemMeta();
+ meta.setOwner(player.getName());
+ meta.setDisplayName(ChatColor.RESET + player.getName());
+ item.setItemMeta(meta);
+ return item;
+ }
+
+ private ItemStack customTexture(String texture, String name) {
+ // A transient model keeps texture creation in the cross-version factory.
+ return named(LegacyItemFactory.createTextureHead(texture), name);
+ }
+
+ private static ItemStack named(ItemStack item, String name) {
+ ItemMeta meta = item.getItemMeta();
+ meta.setDisplayName(name);
+ item.setItemMeta(meta);
+ return item;
+ }
+
+ private static ItemStack item(Material material, String name) { return named(new ItemStack(material), name); }
+ private static Material material(String preferred, String fallback) {
+ Material value = Material.matchMaterial(preferred);
+ if (value == null) value = Material.matchMaterial(fallback);
+ return value == null ? Material.STONE : value;
+ }
+ private static int maxPage(int size) { return Math.max(0, (size - 1) / PAGE_SIZE); }
+ private static String normalize(String value) {
+ return value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "_").replaceAll("^_+|_+$", "");
+ }
+ private boolean allowed(Player player, String category) {
+ return player.hasPermission("headdb.category.*")
+ || player.hasPermission("headdb.category." + normalize(category));
+ }
+ private void deny(Player player) {
+ player.sendMessage(messages.get("noPermission", "No permission!"));
+ sounds.play(player, "noPermission");
+ }
+
+ private static final class MenuHolder implements InventoryHolder {
+ private final String type;
+ private final String key;
+ private final int page;
+ private final List heads;
+ private final Map actions = new HashMap();
+ private Inventory inventory;
+ private MenuHolder(String type, String key, int page, List heads) {
+ this.type = type; this.key = key; this.page = page; this.heads = heads;
+ }
+ @Override public Inventory getInventory() { return inventory; }
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMessages.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMessages.java
new file mode 100644
index 0000000..49fe3f2
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMessages.java
@@ -0,0 +1,47 @@
+package com.bitworksmc.headdb.legacy;
+
+import org.bukkit.ChatColor;
+import org.bukkit.configuration.file.YamlConfiguration;
+
+import java.io.File;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+final class LegacyMessages {
+ private final YamlConfiguration messages;
+
+ LegacyMessages(File dataFolder) {
+ messages = YamlConfiguration.loadConfiguration(new File(dataFolder, "messages/en.yml"));
+ }
+
+ String get(String key, String fallback, String... replacements) {
+ String value = messages.getString(key, fallback);
+ for (int i = 0; i + 1 < replacements.length; i += 2) {
+ value = value.replace("{" + replacements[i] + "}", replacements[i + 1]);
+ }
+ return color(value);
+ }
+
+ static String color(String value) {
+ if (value == null) return "";
+ Map colors = new LinkedHashMap();
+ colors.put("black", ChatColor.BLACK); colors.put("dark_blue", ChatColor.DARK_BLUE);
+ colors.put("dark_green", ChatColor.DARK_GREEN); colors.put("dark_aqua", ChatColor.DARK_AQUA);
+ colors.put("dark_red", ChatColor.DARK_RED); colors.put("dark_purple", ChatColor.DARK_PURPLE);
+ colors.put("gold", ChatColor.GOLD); colors.put("gray", ChatColor.GRAY);
+ colors.put("dark_gray", ChatColor.DARK_GRAY); colors.put("blue", ChatColor.BLUE);
+ colors.put("green", ChatColor.GREEN); colors.put("aqua", ChatColor.AQUA);
+ colors.put("red", ChatColor.RED); colors.put("light_purple", ChatColor.LIGHT_PURPLE);
+ colors.put("yellow", ChatColor.YELLOW); colors.put("white", ChatColor.WHITE);
+ colors.put("bold", ChatColor.BOLD); colors.put("italic", ChatColor.ITALIC);
+ colors.put("underlined", ChatColor.UNDERLINE); colors.put("strikethrough", ChatColor.STRIKETHROUGH);
+ colors.put("reset", ChatColor.RESET);
+ for (Map.Entry entry : colors.entrySet()) {
+ value = value.replace("<" + entry.getKey() + ">", entry.getValue().toString());
+ value = value.replace("" + entry.getKey() + ">", ChatColor.RESET.toString());
+ }
+ value = value.replaceAll("<#[0-9a-fA-F]{6}>", "").replace("", "").replace("", "");
+ value = value.replaceAll("]+>", "Click");
+ return ChatColor.translateAlternateColorCodes('&', value);
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerData.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerData.java
new file mode 100644
index 0000000..0032b7f
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerData.java
@@ -0,0 +1,58 @@
+package com.bitworksmc.headdb.legacy;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+final class LegacyPlayerData {
+ private final UUID uniqueId;
+ private String language = "en";
+ private boolean soundsEnabled = true;
+ private final Set favorites = new LinkedHashSet();
+ private final Set localFavorites = new LinkedHashSet();
+
+ LegacyPlayerData(UUID uniqueId) {
+ this.uniqueId = uniqueId;
+ }
+
+ UUID getUniqueId() { return uniqueId; }
+ String getLanguage() { return language; }
+ void setLanguage(String language) {
+ this.language = language == null || language.trim().isEmpty() ? "en" : language;
+ }
+ boolean isSoundsEnabled() { return soundsEnabled; }
+ void setSoundsEnabled(boolean enabled) { soundsEnabled = enabled; }
+ synchronized boolean isFavorite(int id) { return favorites.contains(id); }
+ synchronized boolean toggleFavorite(int id) {
+ if (favorites.remove(id)) return false;
+ favorites.add(id);
+ return true;
+ }
+ synchronized List getFavorites() { return new ArrayList(favorites); }
+ synchronized void setFavorites(List values) {
+ favorites.clear();
+ if (values != null) favorites.addAll(values);
+ }
+ synchronized boolean isLocalFavorite(UUID id) { return localFavorites.contains(id); }
+ synchronized boolean toggleLocalFavorite(UUID id) {
+ if (localFavorites.remove(id)) return false;
+ localFavorites.add(id);
+ return true;
+ }
+ synchronized List getLocalFavorites() { return new ArrayList(localFavorites); }
+ synchronized void setLocalFavorites(List values) {
+ localFavorites.clear();
+ if (values == null) return;
+ for (String value : values) {
+ try { localFavorites.add(UUID.fromString(value)); } catch (IllegalArgumentException ignored) { }
+ }
+ }
+ synchronized List getLocalFavoriteStrings() {
+ List values = new ArrayList();
+ for (UUID id : localFavorites) values.add(id.toString());
+ return Collections.unmodifiableList(values);
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorage.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorage.java
new file mode 100644
index 0000000..8d50252
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorage.java
@@ -0,0 +1,196 @@
+package com.bitworksmc.headdb.legacy;
+
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.YamlConfiguration;
+
+import java.io.File;
+import java.io.IOException;
+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.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Logger;
+
+final class LegacyPlayerStorage {
+ private final File file;
+ private final String databaseUrl;
+ private final String oldDatabaseUrl;
+ private final Logger logger;
+ private final Map players = new ConcurrentHashMap();
+
+ LegacyPlayerStorage(File dataFolder, Logger logger) {
+ File directory = new File(dataFolder, "data");
+ if (!directory.exists() && !directory.mkdirs()) {
+ throw new IllegalStateException("Could not create " + directory);
+ }
+ this.file = new File(directory, "legacy-players.yml");
+ this.databaseUrl = "jdbc:sqlite:" + new File(directory, "data.db").getAbsolutePath();
+ this.oldDatabaseUrl = "jdbc:sqlite:" + new File(dataFolder, "data.db").getAbsolutePath();
+ this.logger = logger;
+ try {
+ Class.forName("org.sqlite.JDBC");
+ Connection connection = DriverManager.getConnection(databaseUrl);
+ try {
+ connection.createStatement().execute("CREATE TABLE IF NOT EXISTS players (uuid TEXT PRIMARY KEY, language TEXT, favorites TEXT, local_favorites TEXT, sound_enabled INTEGER)");
+ } finally { connection.close(); }
+ } catch (Exception exception) {
+ logger.warning("SQLite initialization failed; YAML fallback will be used: " + exception.getMessage());
+ }
+ }
+
+ LegacyPlayerData get(UUID id) {
+ LegacyPlayerData existing = players.get(id);
+ if (existing != null) return existing;
+ LegacyPlayerData created = new LegacyPlayerData(id);
+ LegacyPlayerData raced = players.putIfAbsent(id, created);
+ return raced == null ? created : raced;
+ }
+
+ synchronized void load() {
+ players.clear();
+ if (loadSqlite()) {
+ if (players.isEmpty() && new File(oldDatabaseUrl.substring("jdbc:sqlite:".length())).isFile()) {
+ importOldDatabase();
+ if (!players.isEmpty()) saveSqlite();
+ }
+ return;
+ }
+ if (!file.isFile()) return;
+ YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file);
+ ConfigurationSection root = yaml.getConfigurationSection("players");
+ if (root == null) return;
+ for (String key : root.getKeys(false)) {
+ try {
+ UUID id = UUID.fromString(key);
+ LegacyPlayerData data = new LegacyPlayerData(id);
+ String path = "players." + key + ".";
+ data.setLanguage(yaml.getString(path + "language", "en"));
+ data.setSoundsEnabled(yaml.getBoolean(path + "sounds", true));
+ data.setFavorites(yaml.getIntegerList(path + "favorites"));
+ data.setLocalFavorites(yaml.getStringList(path + "localFavorites"));
+ players.put(id, data);
+ } catch (IllegalArgumentException ignored) {
+ logger.warning("Ignoring invalid player UUID in " + file.getName() + ": " + key);
+ }
+ }
+ }
+
+ synchronized void save() {
+ if (saveSqlite()) return;
+ YamlConfiguration yaml = new YamlConfiguration();
+ for (LegacyPlayerData data : players.values()) {
+ String path = "players." + data.getUniqueId() + ".";
+ yaml.set(path + "language", data.getLanguage());
+ yaml.set(path + "sounds", data.isSoundsEnabled());
+ yaml.set(path + "favorites", data.getFavorites());
+ yaml.set(path + "localFavorites", data.getLocalFavoriteStrings());
+ }
+ try {
+ yaml.save(file);
+ } catch (IOException exception) {
+ logger.severe("Could not save legacy player data: " + exception.getMessage());
+ }
+ }
+
+ private boolean loadSqlite() {
+ try {
+ Connection connection = DriverManager.getConnection(databaseUrl);
+ try {
+ Statement statement = connection.createStatement();
+ ResultSet rows = statement.executeQuery("SELECT * FROM players");
+ while (rows.next()) {
+ UUID id = UUID.fromString(rows.getString("uuid"));
+ LegacyPlayerData data = new LegacyPlayerData(id);
+ data.setLanguage(rows.getString("language"));
+ int sound = rows.getInt("sound_enabled");
+ data.setSoundsEnabled(rows.wasNull() || sound == 1);
+ data.setFavorites(parseIntegers(rows.getString("favorites")));
+ data.setLocalFavorites(parseStrings(rows.getString("local_favorites")));
+ players.put(id, data);
+ }
+ rows.close(); statement.close();
+ } finally { connection.close(); }
+ return true;
+ } catch (Exception exception) {
+ logger.warning("Could not load SQLite player data: " + exception.getMessage());
+ return false;
+ }
+ }
+
+ private boolean saveSqlite() {
+ try {
+ Connection connection = DriverManager.getConnection(databaseUrl);
+ try {
+ connection.setAutoCommit(false);
+ PreparedStatement statement = connection.prepareStatement(
+ "INSERT OR REPLACE INTO players (uuid, language, favorites, local_favorites, sound_enabled) VALUES (?, ?, ?, ?, ?)");
+ for (LegacyPlayerData data : players.values()) {
+ statement.setString(1, data.getUniqueId().toString());
+ statement.setString(2, data.getLanguage());
+ statement.setString(3, join(data.getFavorites()));
+ statement.setString(4, join(data.getLocalFavoriteStrings()));
+ statement.setInt(5, data.isSoundsEnabled() ? 1 : 0);
+ statement.addBatch();
+ }
+ statement.executeBatch(); statement.close(); connection.commit();
+ } finally { connection.close(); }
+ return true;
+ } catch (SQLException exception) {
+ logger.warning("Could not save SQLite player data: " + exception.getMessage());
+ return false;
+ }
+ }
+
+ private void importOldDatabase() {
+ try {
+ Connection connection = DriverManager.getConnection(oldDatabaseUrl);
+ try {
+ ResultSet rows = connection.createStatement().executeQuery(
+ "SELECT uuid, lang, soundEnabled, favorites FROM hdb_players");
+ while (rows.next()) {
+ UUID id = UUID.fromString(rows.getString("uuid"));
+ LegacyPlayerData data = new LegacyPlayerData(id);
+ data.setLanguage(rows.getString("lang"));
+ boolean enabled = rows.getBoolean("soundEnabled");
+ data.setSoundsEnabled(rows.wasNull() || enabled);
+ data.setFavorites(parseIntegers(rows.getString("favorites")));
+ players.put(id, data);
+ }
+ rows.close();
+ } finally { connection.close(); }
+ logger.info("Imported " + players.size() + " player records from the pre-v6 database.");
+ } catch (Exception exception) {
+ logger.warning("Could not import the pre-v6 player database: " + exception.getMessage());
+ }
+ }
+
+ private static List parseIntegers(String value) {
+ List result = new ArrayList();
+ if (value == null) return result;
+ for (String token : value.split("[,|]")) {
+ try { result.add(Integer.parseInt(token.trim())); } catch (NumberFormatException ignored) { }
+ }
+ return result;
+ }
+ private static List parseStrings(String value) {
+ List result = new ArrayList();
+ if (value == null) return result;
+ for (String token : value.split(",")) if (!token.trim().isEmpty()) result.add(token.trim());
+ return result;
+ }
+ private static String join(List> values) {
+ StringBuilder result = new StringBuilder();
+ for (Object value : values) {
+ if (result.length() > 0) result.append(',');
+ result.append(value);
+ }
+ return result.toString();
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacySounds.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacySounds.java
new file mode 100644
index 0000000..1b22eac
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacySounds.java
@@ -0,0 +1,43 @@
+package com.bitworksmc.headdb.legacy;
+
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.entity.Player;
+import org.bukkit.Bukkit;
+
+import java.io.File;
+
+final class LegacySounds {
+ private final YamlConfiguration sounds;
+ private final LegacyPlayerStorage storage;
+
+ LegacySounds(File dataFolder, LegacyPlayerStorage storage) {
+ this.sounds = YamlConfiguration.loadConfiguration(new File(dataFolder, "sounds.yml"));
+ this.storage = storage;
+ }
+
+ void play(Player player, String key) {
+ if (player == null || !storage.get(player.getUniqueId()).isSoundsEnabled()) return;
+ String sound = sounds.getString(key + ".sound");
+ if (sound == null || sound.trim().isEmpty()) return;
+ if (Bukkit.getBukkitVersion().startsWith("1.8")) sound = sound18(sound);
+ float volume = (float) sounds.getDouble(key + ".volume", 1.0D);
+ float pitch = (float) sounds.getDouble(key + ".pitch", 1.0D);
+ try {
+ player.playSound(player.getLocation(), sound, volume, pitch);
+ } catch (IllegalArgumentException ignored) {
+ // Sound identifiers changed repeatedly; an unknown configured sound is non-fatal.
+ }
+ }
+
+ private static String sound18(String sound) {
+ if (sound.equalsIgnoreCase("block.anvil.land")) return "random.anvil_land";
+ if (sound.equalsIgnoreCase("entity.player.levelup")) return "random.levelup";
+ if (sound.equalsIgnoreCase("block.lever.click")) return "random.click";
+ if (sound.equalsIgnoreCase("entity.arrow.hit.player")) return "random.successful_hit";
+ if (sound.equalsIgnoreCase("entity.bat.takeoff")) return "mob.bat.takeoff";
+ if (sound.equalsIgnoreCase("entity.villager.no")) return "mob.villager.no";
+ if (sound.equalsIgnoreCase("block.wooden_button.click_off")) return "random.click";
+ if (sound.equalsIgnoreCase("block.note_block.pling")) return "note.pling";
+ return sound;
+ }
+}
diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyUpdateChecker.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyUpdateChecker.java
new file mode 100644
index 0000000..d3fb0f2
--- /dev/null
+++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyUpdateChecker.java
@@ -0,0 +1,83 @@
+package com.bitworksmc.headdb.legacy;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerJoinEvent;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+final class LegacyUpdateChecker implements Listener {
+ private static final String RELEASE_URL = "https://api.github.com/repos/BitworksMC/HeadDB/releases/latest";
+ private final LegacyHeadDB plugin;
+ private volatile String latest;
+
+ LegacyUpdateChecker(LegacyHeadDB plugin) {
+ this.plugin = plugin;
+ plugin.getServer().getPluginManager().registerEvents(this, plugin);
+ long hours = Math.max(1L, plugin.getConfig().getLong("updateChecker.intervalHours", 24L));
+ Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::check, 1L, hours * 60L * 60L * 20L);
+ }
+
+ private void check() {
+ HttpURLConnection connection = null;
+ try {
+ connection = (HttpURLConnection) new URL(RELEASE_URL).openConnection();
+ connection.setRequestProperty("Accept", "application/vnd.github+json");
+ connection.setRequestProperty("User-Agent", "HeadDB-Legacy");
+ connection.setConnectTimeout(10000);
+ connection.setReadTimeout(15000);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
+ JsonObject json = new JsonParser().parse(reader).getAsJsonObject();
+ reader.close();
+ String tag = json.get("tag_name").getAsString();
+ if (newer(tag, plugin.getDescription().getVersion())) {
+ latest = tag;
+ if (plugin.getConfig().getBoolean("updateChecker.notifyConsole", true)) {
+ plugin.getLogger().info("A new HeadDB version is available: " + tag);
+ }
+ }
+ } catch (Exception exception) {
+ plugin.getLogger().fine("Update check failed: " + exception.getMessage());
+ } finally {
+ if (connection != null) connection.disconnect();
+ }
+ }
+
+ @EventHandler
+ public void onJoin(PlayerJoinEvent event) {
+ Player player = event.getPlayer();
+ if (latest != null && plugin.getConfig().getBoolean("updateChecker.notifyPlayers", true)
+ && player.hasPermission("headdb.update.notify")) {
+ player.sendMessage(ChatColor.YELLOW + "A new HeadDB version is available: " + ChatColor.GREEN + latest);
+ player.sendMessage(ChatColor.GRAY + "https://github.com/BitworksMC/HeadDB/releases/latest");
+ }
+ }
+
+ static boolean newer(String candidate, String current) {
+ int[] left = numbers(candidate);
+ int[] right = numbers(current);
+ for (int i = 0; i < 3; i++) {
+ if (left[i] != right[i]) return left[i] > right[i];
+ }
+ return false;
+ }
+
+ private static int[] numbers(String value) {
+ String clean = value == null ? "" : value.replaceFirst("^[vV]", "");
+ String[] parts = clean.split("[-+]")[0].split("\\.");
+ int[] result = new int[3];
+ for (int i = 0; i < Math.min(3, parts.length); i++) {
+ try { result[i] = Integer.parseInt(parts[i]); } catch (NumberFormatException ignored) { }
+ }
+ return result;
+ }
+}
diff --git a/headdb-legacy/src/main/resources/plugin.yml b/headdb-legacy/src/main/resources/plugin.yml
new file mode 100644
index 0000000..0859877
--- /dev/null
+++ b/headdb-legacy/src/main/resources/plugin.yml
@@ -0,0 +1,44 @@
+name: HeadDB
+description: ${project.description}
+author: TheSilentPro (Silent)
+main: com.bitworksmc.headdb.legacy.LegacyHeadDB
+version: ${project.version}
+
+commands:
+ headdb:
+ usage: /headdb [info|search|give|sounds]
+ description: Search and give heads from HeadDB
+ aliases: [hdb, headdatabase]
+
+permissions:
+ headdb.admin:
+ description: Grants every legacy HeadDB command.
+ default: op
+ children:
+ headdb.command.search: true
+ headdb.command.give: true
+ headdb.command.info: true
+ headdb.command.sounds: true
+ headdb.command.open: true
+ headdb.category.*: true
+ headdb.update.notify: true
+ headdb.command.search:
+ default: op
+ headdb.command.give:
+ default: op
+ headdb.command.info:
+ default: true
+ headdb.command.sounds:
+ default: true
+ headdb.command.open:
+ default: op
+ headdb.category.*:
+ default: op
+ headdb.category.favorites:
+ default: op
+ headdb.category.local:
+ default: op
+ headdb.category.custom:
+ default: op
+ headdb.update.notify:
+ default: op
diff --git a/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyDatabaseTest.java b/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyDatabaseTest.java
new file mode 100644
index 0000000..18e3940
--- /dev/null
+++ b/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyDatabaseTest.java
@@ -0,0 +1,90 @@
+package com.bitworksmc.headdb.legacy;
+
+import com.bitworksmc.headdb.api.model.Head;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+public class LegacyDatabaseTest {
+ private HttpServer server;
+ private ExecutorService executor;
+
+ @Before
+ public void setUp() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ executor = Executors.newSingleThreadExecutor();
+ }
+
+ @After
+ public void tearDown() {
+ if (server != null) {
+ server.stop(0);
+ }
+ if (executor != null) {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void loadsAndIndexesLegacyDatabase() {
+ serve("["
+ + "{\"id\":1,\"name\":\"Apple\",\"texture\":\"aaa\",\"category\":\"Food\",\"tags\":[\"fruit\"]},"
+ + "{\"id\":2,\"name\":\"Pear\",\"texture\":\"bbb\",\"category\":\"Food\",\"tags\":[\"fruit\",\"green\"]}"
+ + "]", 200);
+
+ LegacyDatabase database = new LegacyDatabase(url(), executor);
+ List heads = database.update().join();
+
+ assertTrue(database.isReady());
+ assertEquals(2, heads.size());
+ assertSame(heads.get(0), database.getById(1));
+ assertSame(heads.get(1), database.getByTexture("bbb"));
+ assertEquals(2, database.getByCategory("food").size());
+ assertEquals(2, database.getByTags("fruit").size());
+ assertEquals(1, database.getByTags("green").size());
+ }
+
+ @Test
+ public void failedUpdateDoesNotPublishSnapshot() {
+ serve("unavailable", 503);
+
+ LegacyDatabase database = new LegacyDatabase(url(), executor);
+ try {
+ database.update().join();
+ } catch (RuntimeException expected) {
+ // CompletionException is the public CompletableFuture failure contract.
+ }
+
+ assertFalse(database.isReady());
+ assertFalse(database.awaitReady());
+ }
+
+ private void serve(final String body, final int status) {
+ server.createContext("/heads.json", exchange -> {
+ byte[] response = body.getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(status, response.length);
+ OutputStream output = exchange.getResponseBody();
+ output.write(response);
+ output.close();
+ });
+ server.start();
+ }
+
+ private String url() {
+ return "http://127.0.0.1:" + server.getAddress().getPort() + "/heads.json";
+ }
+}
diff --git a/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorageTest.java b/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorageTest.java
new file mode 100644
index 0000000..6d95641
--- /dev/null
+++ b/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorageTest.java
@@ -0,0 +1,54 @@
+package com.bitworksmc.headdb.legacy;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.util.Arrays;
+import java.util.UUID;
+import java.util.logging.Logger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class LegacyPlayerStorageTest {
+ @Rule public final TemporaryFolder temporary = new TemporaryFolder();
+
+ @Test
+ public void persistsModernCompatiblePlayerSchema() throws Exception {
+ UUID player = UUID.randomUUID();
+ UUID local = UUID.randomUUID();
+ LegacyPlayerStorage first = new LegacyPlayerStorage(temporary.newFolder("HeadDB"), Logger.getAnonymousLogger());
+ LegacyPlayerData data = first.get(player);
+ data.setLanguage("fr");
+ data.setSoundsEnabled(false);
+ data.toggleFavorite(42);
+ data.toggleLocalFavorite(local);
+ first.save();
+
+ LegacyPlayerStorage second = new LegacyPlayerStorage(temporary.getRoot().toPath().resolve("HeadDB").toFile(), Logger.getAnonymousLogger());
+ second.load();
+ LegacyPlayerData loaded = second.get(player);
+ assertEquals("fr", loaded.getLanguage());
+ assertFalse(loaded.isSoundsEnabled());
+ assertEquals(Arrays.asList(42), loaded.getFavorites());
+ assertEquals(Arrays.asList(local), loaded.getLocalFavorites());
+ }
+
+ @Test
+ public void togglesFavoritesWithoutDuplicates() {
+ LegacyPlayerData data = new LegacyPlayerData(UUID.randomUUID());
+ assertTrue(data.toggleFavorite(7));
+ assertTrue(data.isFavorite(7));
+ assertFalse(data.toggleFavorite(7));
+ assertFalse(data.isFavorite(7));
+ }
+
+ @Test
+ public void comparesReleaseVersions() {
+ assertTrue(LegacyUpdateChecker.newer("v6.0.4", "6.0.3"));
+ assertFalse(LegacyUpdateChecker.newer("v6.0.3", "6.0.3"));
+ assertFalse(LegacyUpdateChecker.newer("v5.9.9", "6.0.3"));
+ }
+}
diff --git a/pom.xml b/pom.xml
index 7ac9f3b..b23c812 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
com.bitworksmc
HeadDB
- 6.0.2
+ 6.0.3
pom
HeadDB
@@ -16,15 +16,17 @@
headdb-api
headdb-core
+ headdb-legacy
UTF-8
- 25
+ 21
5.2.0
26.1.0
26.2
- ${paper.minecraft.version}.build.112-stable
+ 1.21-R0.1-SNAPSHOT
+ 1.8.8-R0.1-SNAPSHOT
3.53.2.1
diff --git a/scripts/smoke-test-legacy.ps1 b/scripts/smoke-test-legacy.ps1
new file mode 100644
index 0000000..45d7f7a
--- /dev/null
+++ b/scripts/smoke-test-legacy.ps1
@@ -0,0 +1,220 @@
+[CmdletBinding()]
+param(
+ [string] $PluginJar = 'headdb-legacy/target/HeadDB-6.0.3-legacy.jar',
+ [string] $WorkDirectory = 'build/legacy-smoke',
+ [int] $StartupTimeoutSeconds = 120,
+ [string[]] $MinecraftVersions
+)
+
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+
+$versions = @(
+ '1.8.8', '1.9.4', '1.10.2', '1.11.2', '1.12', '1.12.1', '1.12.2',
+ '1.13', '1.13.1', '1.13.2', '1.14', '1.14.1', '1.14.2', '1.14.3', '1.14.4',
+ '1.15', '1.15.1', '1.15.2', '1.16.1', '1.16.2', '1.16.3', '1.16.4', '1.16.5',
+ '1.17', '1.17.1', '1.18', '1.18.1', '1.18.2',
+ '1.19', '1.19.1', '1.19.2', '1.19.3', '1.19.4',
+ '1.20', '1.20.1', '1.20.2', '1.20.4', '1.20.5', '1.20.6'
+)
+if ($null -ne $MinecraftVersions -and $MinecraftVersions.Count -gt 0) {
+ $unknown = $MinecraftVersions | Where-Object { $_ -notin $versions }
+ if ($unknown) { throw "Unsupported smoke-test version(s): $($unknown -join ', ')" }
+ $versions = $MinecraftVersions
+}
+
+function Get-JavaFeature([string] $version) {
+ $parts = $version.Split('.')
+ $minor = [int] $parts[1]
+ $patch = if ($parts.Length -gt 2) { [int] $parts[2] } else { 0 }
+ if ($minor -le 16) { return 8 }
+ if ($minor -eq 17) { return 16 }
+ if ($minor -eq 20 -and $patch -ge 5) { return 21 }
+ return 17
+}
+
+function Get-Sha256([string] $path) {
+ $stream = [IO.File]::OpenRead($path)
+ try {
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try { return (($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '') }
+ finally { $sha.Dispose() }
+ } finally { $stream.Dispose() }
+}
+
+function Get-Sha1([string] $path) {
+ $stream = [IO.File]::OpenRead($path)
+ try {
+ $sha = [Security.Cryptography.SHA1]::Create()
+ try { return (($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '') }
+ finally { $sha.Dispose() }
+ } finally { $stream.Dispose() }
+}
+
+function Get-MojangServer([string] $version, [string] $cacheRoot) {
+ [IO.Directory]::CreateDirectory($cacheRoot) | Out-Null
+ $jar = Join-Path $cacheRoot "minecraft_server.$version.jar"
+ if (Test-Path -LiteralPath $jar -PathType Leaf) { return $jar }
+ $manifest = Invoke-RestMethod -Uri 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json' -TimeoutSec 30
+ $versionEntry = $manifest.versions | Where-Object { $_.id -eq $version } | Select-Object -First 1
+ if ($null -eq $versionEntry) { throw "Mojang manifest has no server $version" }
+ $metadata = Invoke-RestMethod -Uri $versionEntry.url -TimeoutSec 30
+ if ($null -eq $metadata.downloads.server) { throw "Mojang publishes no server jar for $version" }
+ Write-Host "Downloading the official Mojang $version server jar..."
+ Invoke-WebRequest -Uri $metadata.downloads.server.url -OutFile $jar -UseBasicParsing -TimeoutSec 300
+ if ((Get-Sha1 $jar) -ne ([string] $metadata.downloads.server.sha1).ToLowerInvariant()) {
+ Remove-Item -LiteralPath $jar -Force
+ throw "Mojang $version checksum mismatch"
+ }
+ return $jar
+}
+
+function Install-Java([int] $feature, [string] $runtimeRoot) {
+ $destination = Join-Path $runtimeRoot "java-$feature"
+ $java = Join-Path $destination 'bin/java.exe'
+ if (Test-Path -LiteralPath $java -PathType Leaf) { return $java }
+
+ [IO.Directory]::CreateDirectory($runtimeRoot) | Out-Null
+ $archive = Join-Path $runtimeRoot "java-$feature.zip"
+ Write-Host "Downloading Eclipse Temurin Java $feature..."
+ $downloaded = $false
+ foreach ($imageType in 'jre', 'jdk') {
+ $uri = "https://api.adoptium.net/v3/binary/latest/$feature/ga/windows/x64/$imageType/hotspot/normal/eclipse"
+ try {
+ Invoke-WebRequest -Uri $uri -OutFile $archive -UseBasicParsing -TimeoutSec 300
+ $downloaded = $true
+ break
+ } catch {
+ if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
+ }
+ }
+ if (-not $downloaded) { throw "No Eclipse Temurin Java $feature JRE or JDK package is available" }
+ $extract = Join-Path $runtimeRoot "extract-$feature"
+ if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force }
+ Expand-Archive -LiteralPath $archive -DestinationPath $extract
+ $extractedJava = Get-ChildItem -LiteralPath $extract -Filter java.exe -Recurse |
+ Where-Object { $_.FullName -match '[\\/]bin[\\/]java\.exe$' } | Select-Object -First 1
+ if ($null -eq $extractedJava) { throw "Java $feature archive did not contain bin/java.exe" }
+ $jdkRoot = Split-Path (Split-Path $extractedJava.FullName -Parent) -Parent
+ Move-Item -LiteralPath $jdkRoot -Destination $destination
+ Remove-Item -LiteralPath $extract -Recurse -Force
+ Remove-Item -LiteralPath $archive -Force
+ return $java
+}
+
+function Get-Paper([string] $version, [string] $cacheRoot) {
+ [IO.Directory]::CreateDirectory($cacheRoot) | Out-Null
+ $jar = Join-Path $cacheRoot "paper-$version.jar"
+ if (Test-Path -LiteralPath $jar -PathType Leaf) { return $jar }
+
+ $headers = @{ Accept = 'application/json' }
+ $agent = 'HeadDB-legacy-smoke/6.0.3 (https://github.com/BitworksMC/HeadDB)'
+ $builds = Invoke-RestMethod -Uri "https://fill.papermc.io/v3/projects/paper/versions/$version/builds" `
+ -Headers $headers -UserAgent $agent -TimeoutSec 30
+ $build = $builds | Where-Object { $_.channel -eq 'STABLE' } | Select-Object -First 1
+ if ($null -eq $build) { $build = $builds | Select-Object -First 1 }
+ if ($null -eq $build) { throw "No Paper build exists for $version" }
+ $download = $build.downloads.PSObject.Properties['server:default'].Value
+ Write-Host "Downloading Paper $version build $($build.id)..."
+ Invoke-WebRequest -Uri $download.url -OutFile $jar -UseBasicParsing -UserAgent $agent -TimeoutSec 300
+ $actual = Get-Sha256 $jar
+ if ($actual -ne ([string] $download.checksums.sha256).ToLowerInvariant()) {
+ Remove-Item -LiteralPath $jar -Force
+ throw "Paper $version checksum mismatch"
+ }
+ return $jar
+}
+
+$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$plugin = (Resolve-Path (Join-Path $repository $PluginJar)).Path
+$root = [IO.Path]::GetFullPath((Join-Path $repository $WorkDirectory))
+$cache = Join-Path $root 'cache'
+$runtimeRoot = Join-Path $cache 'java'
+$paperRoot = Join-Path $cache 'paper'
+$mojangRoot = Join-Path $cache 'mojang'
+$results = [Collections.Generic.List[object]]::new()
+
+foreach ($feature in 8, 16, 17, 21) { [void](Install-Java $feature $runtimeRoot) }
+
+foreach ($version in $versions) {
+ $started = Get-Date
+ $status = 'FAIL'
+ $detail = ''
+ $process = $null
+ try {
+ $server = Join-Path $root "servers/$version"
+ $plugins = Join-Path $server 'plugins'
+ [IO.Directory]::CreateDirectory($plugins) | Out-Null
+ Copy-Item -LiteralPath $plugin -Destination (Join-Path $plugins 'HeadDB.jar') -Force
+ Copy-Item -LiteralPath (Get-Paper $version $paperRoot) -Destination (Join-Path $server 'paper.jar') -Force
+ $minor = [int] $version.Split('.')[1]
+ if ($minor -le 12) {
+ $serverCache = Join-Path $server 'cache'
+ [IO.Directory]::CreateDirectory($serverCache) | Out-Null
+ Copy-Item -LiteralPath (Get-MojangServer $version $mojangRoot) `
+ -Destination (Join-Path $serverCache "mojang_$version.jar") -Force
+ }
+ [IO.File]::WriteAllText((Join-Path $server 'eula.txt'), "eula=true`r`n", [Text.Encoding]::ASCII)
+ [IO.File]::WriteAllText((Join-Path $server 'server.properties'), "online-mode=false`r`nserver-port=0`r`nmax-tick-time=-1`r`n", [Text.Encoding]::ASCII)
+
+ $stdout = Join-Path $server 'smoke-stdout.log'
+ $stderr = Join-Path $server 'smoke-stderr.log'
+ $java = Install-Java (Get-JavaFeature $version) $runtimeRoot
+ $bootstrapOut = Join-Path $server 'bootstrap-stdout.log'
+ $bootstrapErr = Join-Path $server 'bootstrap-stderr.log'
+ $bootstrap = Start-Process -FilePath $java `
+ -ArgumentList '-Djava.awt.headless=true','-Xms256M','-Xmx768M','-jar','paper.jar','--version' `
+ -WorkingDirectory $server -RedirectStandardOutput $bootstrapOut -RedirectStandardError $bootstrapErr `
+ -WindowStyle Hidden -PassThru -Wait
+ if ($bootstrap.ExitCode -ne 0) { throw "Paper bootstrap/version check exited with code $($bootstrap.ExitCode)" }
+ $process = Start-Process -FilePath $java -ArgumentList '-Djava.awt.headless=true','-Xms256M','-Xmx768M','-jar','paper.jar' `
+ -WorkingDirectory $server -RedirectStandardOutput $stdout -RedirectStandardError $stderr `
+ -WindowStyle Hidden -PassThru
+
+ $deadline = (Get-Date).AddSeconds($StartupTimeoutSeconds)
+ do {
+ Start-Sleep -Milliseconds 500
+ $process.Refresh()
+ $log = if (Test-Path -LiteralPath $stdout) { Get-Content -LiteralPath $stdout -Raw } else { '' }
+ if ($process.HasExited) { break }
+ } while ((Get-Date) -lt $deadline -and
+ ($log -notmatch '\[HeadDB\] Enabled the Minecraft 1\.8\.8-1\.20\.6 legacy implementation\.' -or
+ $log -notmatch '\[HeadDB\] Loaded [0-9,]+ heads\.' -or
+ $log -notmatch 'Done \([0-9.]+s\)!'))
+
+ if ($log -match '\[HeadDB\] Enabled the Minecraft 1\.8\.8-1\.20\.6 legacy implementation\.' -and
+ $log -match '\[HeadDB\] Loaded [0-9,]+ heads\.' -and
+ $log -match 'Done \([0-9.]+s\)!' -and
+ $log -notmatch '(?im)^.*\[HeadDB\].*(ERROR|SEVERE|Exception)') {
+ $status = 'PASS'
+ $detail = ([regex]::Match($log, 'Loaded [0-9,]+ heads').Value)
+ } elseif ($process.HasExited) {
+ $detail = "Server exited with code $($process.ExitCode)"
+ } else {
+ $detail = "Startup exceeded $StartupTimeoutSeconds seconds"
+ }
+ } catch {
+ $detail = $_.Exception.Message
+ } finally {
+ if ($null -ne $process -and -not $process.HasExited) {
+ Stop-Process -Id $process.Id -Force
+ $process.WaitForExit(10000) | Out-Null
+ }
+ }
+
+ $result = [pscustomobject]@{
+ Version = $version
+ Java = Get-JavaFeature $version
+ Status = $status
+ Seconds = [math]::Round(((Get-Date) - $started).TotalSeconds, 1)
+ Detail = $detail
+ }
+ $results.Add($result)
+ $result | Format-Table -AutoSize
+}
+
+$csv = Join-Path $root 'results.csv'
+$results | Export-Csv -LiteralPath $csv -NoTypeInformation
+$results | Format-Table -AutoSize
+Write-Host "Results: $csv"
+if ($results.Status -contains 'FAIL') { exit 1 }