From 36bcb1491380fc289d52f7175c998684a77e2f68 Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Mon, 17 Aug 2026 14:07:31 -0700 Subject: [PATCH] Add modern and legacy compatibility artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds separate modern and legacy HeadDB 6.0.3 artifacts: - `HeadDB-6.0.3.jar` for Paper 1.21+ - `HeadDB-6.0.3-legacy.jar` for Bukkit-compatible servers from 1.8.8 through 1.20.6 ## Legacy support The new `headdb-legacy` module provides: - Browsing, category, search, favorites, local-head, custom-category, and purchase menus - Persistent player settings and favorites using the existing SQLite schema - Migration from the pre-v6 player database - Vault economy integration and configurable pricing - Legacy and modern textured-skull profile handling - Structured search filters - `/hdb info`, `/hdb search`, `/hdb give`, `/hdb open`, and `/hdb sounds` - Public and deprecated API service compatibility - Configurable messages, sounds, and permissions - Metrics and update notifications - Scheduled database refreshes, preload support, and fallback database sources Legacy menus use Bukkit inventories. Purchase quantities use inventory presets, and unsupported MiniMessage effects are simplified on older clients. ## Build and release changes - Adds `headdb-legacy` as a Maven module - Targets Java 8 bytecode for the legacy jar - Targets Java 21 for the modern jar - Compiles the modern plugin against Paper 1.21 - Removes the previous `-Spigot.jar` classifier - Updates the release workflow to upload both jars - Adds API compatibility and runtime smoke-test tooling - Updates installation documentation and the 6.0.3 changelog ## Validation - Full Maven reactor build passed - 55 automated tests passed - Legacy API compilation passed against: - 1.8.8 - 1.13.2 - 1.16.5 - 1.18.2 - 1.20.6 - Java bytecode audit passed: - Legacy: Java 8 compatible - Modern: Java 21 compatible - Runtime smoke tests passed on all 39 historical Paper releases published between 1.8.8 and 1.20.6 - Every runtime test: - Reached server-ready state - Enabled HeadDB successfully - Loaded all 86,248 heads - Reported no HeadDB startup errors ## Compatibility | Artifact | Minecraft versions | Runtime | |---|---|---| | `HeadDB-6.0.3.jar` | Paper 1.21+ | Java 21+ | | `HeadDB-6.0.3-legacy.jar` | Bukkit-compatible 1.8.8–1.20.6 | Java version required by the server | Only one HeadDB artifact should be installed on a server. --- .github/workflows/compatibility.yml | 60 +++ .github/workflows/release.yml | 4 +- README.md | 43 +- changelog/6.0.3.md | 34 ++ headdb-api/pom.xml | 20 +- .../thesilentpro/headdb/api/HeadAPI.java | 2 +- .../thesilentpro/headdb/api/HeadDatabase.java | 2 +- .../thesilentpro/headdb/api/model/Head.java | 2 +- headdb-core/pom.xml | 67 +-- headdb-core/src/main/resources/plugin.yml | 2 +- headdb-legacy/pom.xml | 136 ++++++ .../headdb/legacy/LegacyApiAdapter.java | 57 +++ .../headdb/legacy/LegacyDatabase.java | 252 +++++++++++ .../headdb/legacy/LegacyEconomy.java | 66 +++ .../bitworksmc/headdb/legacy/LegacyHead.java | 71 +++ .../headdb/legacy/LegacyHeadAPI.java | 127 ++++++ .../headdb/legacy/LegacyHeadDB.java | 403 ++++++++++++++++++ .../headdb/legacy/LegacyItemFactory.java | 194 +++++++++ .../headdb/legacy/LegacyMenuManager.java | 391 +++++++++++++++++ .../headdb/legacy/LegacyMessages.java | 47 ++ .../headdb/legacy/LegacyPlayerData.java | 58 +++ .../headdb/legacy/LegacyPlayerStorage.java | 196 +++++++++ .../headdb/legacy/LegacySounds.java | 43 ++ .../headdb/legacy/LegacyUpdateChecker.java | 83 ++++ headdb-legacy/src/main/resources/plugin.yml | 44 ++ .../headdb/legacy/LegacyDatabaseTest.java | 90 ++++ .../legacy/LegacyPlayerStorageTest.java | 54 +++ pom.xml | 8 +- scripts/smoke-test-legacy.ps1 | 220 ++++++++++ 29 files changed, 2698 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/compatibility.yml create mode 100644 changelog/6.0.3.md create mode 100644 headdb-legacy/pom.xml create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyApiAdapter.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyDatabase.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyEconomy.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHead.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadAPI.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMenuManager.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMessages.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerData.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorage.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacySounds.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyUpdateChecker.java create mode 100644 headdb-legacy/src/main/resources/plugin.yml create mode 100644 headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyDatabaseTest.java create mode 100644 headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorageTest.java create mode 100644 scripts/smoke-test-legacy.ps1 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 values) { + return new ArrayList(values); + } + private static Optional head( + Optional 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 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("", 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 }