Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6237946e | ||
|
|
68d4a854c3 | ||
|
|
a6c80e7ac9 | ||
|
|
960e8891a8 |
No files matched your search
@@ -35,6 +35,6 @@ jobs:
|
||||
- name: Upload development artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-heights-${{ github.sha }}
|
||||
name: purpur-heights-${{ github.sha }}
|
||||
path: build/libs/*.jar
|
||||
if-no-files-found: error
|
||||
@@ -86,8 +86,8 @@ jobs:
|
||||
if: steps.release.outputs.created == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-heights-${{ steps.release.outputs.version }}
|
||||
path: build/libs/spigot-heights-${{ steps.release.outputs.version }}.jar
|
||||
name: purpur-heights-${{ steps.release.outputs.version }}
|
||||
path: build/libs/purpur-heights-${{ steps.release.outputs.version }}.jar
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Gitea release and upload plugin
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
|
||||
run: |
|
||||
api_url="${GITEA_SERVER_URL}/api/v1"
|
||||
jar="build/libs/spigot-heights-${VERSION}.jar"
|
||||
jar="build/libs/purpur-heights-${VERSION}.jar"
|
||||
export RELEASE_BODY
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
|
||||
@@ -138,4 +138,4 @@ jobs:
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${jar}" \
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-heights-${VERSION}.jar"
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-heights-${VERSION}.jar"
|
||||
@@ -1,9 +1,9 @@
|
||||
# spigot-heights agent entrypoint
|
||||
# purpur-heights agent entrypoint
|
||||
|
||||
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-heights/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-heights/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-heights/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-heights/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-heights/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-heights/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
@@ -1,12 +1,14 @@
|
||||
# Spigot Heights
|
||||
# Purpur Heights
|
||||
|
||||
A Purpur 26.2 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players.
|
||||
A Purpur 26.3 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players.
|
||||
|
||||
Approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-heights/index.md).
|
||||
Approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-heights/index.md).
|
||||
|
||||
New releases use the `purpur-heights` repository and `purpur-heights-<version>.jar` distribution name. Runtime `SpigotHeights`, its existing data folder, Java entrypoint/packages, permissions and stature-potion/recipe keys remain unchanged. Replace the old JAR instead of installing both distributions. Historical tags and `spigot-heights-*` assets are retained; the previous v1.5.0 download was checked for identical bytes after the rename.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Purpur 26.2 build 2618
|
||||
- Purpur 26.3 build 2651
|
||||
- Java 25 or newer
|
||||
|
||||
## Potions
|
||||
@@ -55,9 +57,9 @@ Drinking a permanent stature potion ends the temporary sequence, using the curre
|
||||
|
||||
Heights tracks only its loaded clouds and checks them every five ticks, dispatching `AreaEffectCloudApplyEvent` and honoring cancellation and recipient filtering. This supports effectless stature clouds without adding a vanilla status effect. Online expiry is checked every second. Durable receipts survive cloud unload and are retired on observed permanent entity removal; abrupt process termination can leave harmless orphan receipts.
|
||||
|
||||
Heights corrects Purpur 2618's no-op conversion of custom potions without a vanilla base potion type inside `BrewEvent`. This covers existing items without changing metadata or crafting recipes. It detaches aliased results at LOWEST priority before normal result modifiers and validates/corrects at HIGHEST. Conflicting/missing results or item-construction failures cancel the brew; valid converted results and native ingredient/fuel accounting are retained. Cancellation remains authoritative. As with other Bukkit listeners, this cannot control plugins that directly mutate inventories or run conflicting handlers outside the normal event-priority contract.
|
||||
Purpur 26.3's data-driven brewing engine natively converts authenticated custom potions without a vanilla base potion type. Heights supplies representative recipe choices so the server's brewing input/reagent indexes include those recipes, then validates results at the `BrewEvent` transaction boundary. The build-2618 no-op correction is not needed for normal build-2651 conversion; the defensive listener preserves cancellation and rejects conflicting, missing or identity-stripped results. Valid native results, compatible result modifiers, ingredient/fuel accounting, and dragon's-breath bottle return remain server-owned. Cancellation remains authoritative. As with other Bukkit listeners, this cannot control plugins that directly mutate inventories or run conflicting handlers outside the normal event-priority contract.
|
||||
|
||||
**Verification:** `nativeBrewingTest` executes real Purpur 2618 brewing cycles with native inventories, recipes, metadata and Paper event dispatch. It covers all four splash/lingering conversions, ingredient safety and cancellation, mixed/ordinary potions, and output compatibility with the stature adapters. World and entity boundaries are test doubles; these checks do not claim native projectile/cloud spawning, client appearance, or live logout/restart gameplay. Those remain supplementary live checks.
|
||||
**Verification:** `nativeBrewingTest` executes real Purpur 26.3 build 2651 brewing cycles with native data-driven recipes, inventories, metadata and Paper event dispatch. It proves all four base-less potion kinds convert without the old correction, then covers splash/lingering conversion, representative recipe indexing, ingredient safety and cancellation, mixed/ordinary potions, immediate second-stage brewing, bottle return, and output compatibility with the stature adapters. World and entity boundaries are test doubles; these checks do not claim native projectile/cloud spawning, client appearance, or live logout/restart gameplay. Those remain supplementary live checks.
|
||||
|
||||
## Ride larger players (US-010)
|
||||
|
||||
@@ -87,7 +89,7 @@ Eligibility is rechecked every five ticks, including temporary potion applicatio
|
||||
Server/plugin cancellation remains authoritative: a refused dismount leaves accurate tracking and is reported, not bypassed or called successful. A cancelled voluntary stop can be retried by either player; automatic eligibility cleanup continues checking and reports refusal once per ride rather than spamming. Lifecycle/disable refusals are logged. Native dismount events are reconciled after dispatch so a later cancellation is honored. An unrelated vehicle is never ejected during stale-pair cleanup.
|
||||
|
||||
**Verification:** API/domain tests preserve native cleanup and reject rescue/motion-reset calls.
|
||||
`NativeCarryRoutingTest` registers the production listener through Purpur 2618's actual Paper
|
||||
`NativeCarryRoutingTest` registers the production listener through Purpur 26.3 build 2651's actual Paper
|
||||
plugin manager and dispatches both `PlayerInteractEntityEvent` and `PlayerInteractAtEntityEvent`.
|
||||
Same-tick duplicate rider/target interactions are coalesced, including refused mounts; a fresh
|
||||
interaction next tick can retry without a gameplay cooldown. Registered mount/dismount cancellation
|
||||
@@ -159,7 +161,7 @@ Player names must match an online player exactly (case-insensitive); partial nam
|
||||
|
||||
The plugin JAR is written to `build/libs/`.
|
||||
|
||||
`check` includes `nativeBrewingTest`. It downloads the exact Purpur 26.2 build 2618 launcher (SHA-256 verified) and prepares its runtime dependencies with Paperclip's **patch-only** mode under `build/brewing-runtime/`. It does not start a server, open ports, create a world or accept the EULA. Initial/clean builds need network access; the native test JVM permits up to 1 GiB heap. No runtime implementation classes or test dependencies are packaged in the plugin. Run `./gradlew test` for the fast API/domain suite alone, or `./gradlew nativeBrewingTest` for native brewing and registered carry-event routing tests. Full verification remains `./gradlew clean check jar`.
|
||||
`check` includes `nativeBrewingTest`. It downloads the exact Purpur 26.3 build 2651 launcher (SHA-256 `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`) and prepares its runtime dependencies with Paperclip's **patch-only** mode under `build/brewing-runtime/`. It does not start a server, open ports, create a world or accept the EULA. Initial/clean builds need network access; the native test JVM permits up to 1 GiB heap. No runtime implementation classes or test dependencies are packaged in the plugin. Run `./gradlew test` for the fast API/domain suite alone, or `./gradlew nativeBrewingTest` for native brewing and registered carry-event routing tests. Full verification remains `./gradlew clean check jar`.
|
||||
|
||||
## Releases
|
||||
|
||||
|
||||
+10
-7
@@ -24,8 +24,8 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.yaml:snakeyaml:2.4")
|
||||
@@ -34,25 +34,28 @@ dependencies {
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
dependsOn(tasks.jar)
|
||||
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
|
||||
systemProperty("distribution.version", project.version.toString())
|
||||
}
|
||||
|
||||
// Exercise the declared runtime's native brewing engine, not a recipe-registry double.
|
||||
// Paperclip only extracts/patches dependencies: no server, world, port or EULA acceptance.
|
||||
val brewingRuntime = layout.buildDirectory.dir("brewing-runtime")
|
||||
val downloadBrewingRuntime = tasks.register("downloadBrewingRuntime") {
|
||||
val launcher = brewingRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||
val launcher = brewingRuntime.map { it.file("purpur-26.3-2651.jar") }
|
||||
outputs.file(launcher)
|
||||
doLast {
|
||||
val file = launcher.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download").toURL().openConnection()
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
val bytes = connection.getInputStream().use { it.readBytes() }
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
|
||||
"Purpur 2618 checksum mismatch"
|
||||
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
|
||||
"Purpur 2651 checksum mismatch"
|
||||
}
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
@@ -60,7 +63,7 @@ val downloadBrewingRuntime = tasks.register("downloadBrewingRuntime") {
|
||||
val prepareBrewingRuntime = tasks.register<JavaExec>("prepareBrewingRuntime") {
|
||||
dependsOn(downloadBrewingRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(brewingRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||
classpath = files(brewingRuntime.map { it.file("purpur-26.3-2651.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(brewingRuntime)
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "spigot-heights"
|
||||
rootProject.name = "purpur-heights"
|
||||
@@ -1,7 +1,12 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import io.papermc.paper.potion.PotionMix;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
@@ -13,6 +18,7 @@ import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.meta.PotionMeta;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.potion.PotionType;
|
||||
@@ -26,6 +32,8 @@ public final class PotionRecipes {
|
||||
private final Server server;
|
||||
private final NamespacedKey potionKindKey;
|
||||
private final Function<Material, ItemStack> items;
|
||||
private final List<NamespacedKey> ownedRecipeKeys = new ArrayList<>();
|
||||
private final Set<NamespacedKey> ownedMixKeys = new LinkedHashSet<>();
|
||||
|
||||
public PotionRecipes(JavaPlugin plugin) {
|
||||
this(plugin.getServer(), new NamespacedKey(plugin, "stature_potion"), ItemStack::new);
|
||||
@@ -41,12 +49,42 @@ public final class PotionRecipes {
|
||||
return new NamespacedKey(potionKindKey.getNamespace(), value);
|
||||
}
|
||||
|
||||
public void register() {
|
||||
registerShifting();
|
||||
registerGrowth();
|
||||
registerDiminution();
|
||||
registerRestoration();
|
||||
public boolean register() {
|
||||
boolean complete = registerCrafting();
|
||||
registerBrewing(server.getPotionBrewer());
|
||||
return complete;
|
||||
}
|
||||
|
||||
private boolean registerCrafting() {
|
||||
for (ShapedRecipe recipe : List.of(shiftingRecipe(), growthRecipe(), diminutionRecipe(), restorationRecipe())) {
|
||||
NamespacedKey recipeKey = recipe.getKey();
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
rollbackCrafting();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!server.addRecipe(recipe)) {
|
||||
rollbackCrafting();
|
||||
return false;
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
ownedRecipeKeys.add(recipeKey);
|
||||
}
|
||||
} catch (RuntimeException inspectionFailure) {
|
||||
exception.addSuppressed(inspectionFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
ownedRecipeKeys.add(recipeKey);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void rollbackCrafting() {
|
||||
ownedRecipeKeys.forEach(server::removeRecipe);
|
||||
ownedRecipeKeys.clear();
|
||||
}
|
||||
|
||||
public void registerBrewing(org.bukkit.potion.PotionBrewer brewer) {
|
||||
@@ -61,16 +99,45 @@ public final class PotionRecipes {
|
||||
NamespacedKey mixKey = mixKey(kind, target);
|
||||
brewer.removePotionMix(mixKey);
|
||||
brewer.addPotionMix(new PotionMix(mixKey, create(kind, target),
|
||||
PotionMix.createPredicateChoice(item -> item != null && item.getType() == source
|
||||
predicateChoice(create(kind, source), item -> item != null && item.getType() == source
|
||||
&& identify(item) == kind),
|
||||
PotionMix.createPredicateChoice(item -> item != null && item.getType() == ingredient)));
|
||||
predicateChoice(items.apply(ingredient), item -> item != null && item.getType() == ingredient)));
|
||||
ownedMixKeys.add(mixKey);
|
||||
}
|
||||
|
||||
private static RecipeChoice.PredicateChoice predicateChoice(
|
||||
ItemStack example, Predicate<? super ItemStack> predicate) {
|
||||
return new RecipeChoice.PredicateChoice() {
|
||||
@Override
|
||||
public ItemStack getItemStack() {
|
||||
return example.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RecipeChoice.PredicateChoice clone() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean test(ItemStack item) {
|
||||
return predicate.test(item);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
ownedRecipeKeys.forEach(server::removeRecipe);
|
||||
ownedRecipeKeys.clear();
|
||||
unregisterBrewing();
|
||||
}
|
||||
|
||||
public void unregisterBrewing() {
|
||||
for (StaturePotion kind : StaturePotion.values()) {
|
||||
server.getPotionBrewer().removePotionMix(mixKey(kind, Material.SPLASH_POTION));
|
||||
server.getPotionBrewer().removePotionMix(mixKey(kind, Material.LINGERING_POTION));
|
||||
if (ownedMixKeys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
org.bukkit.potion.PotionBrewer brewer = server.getPotionBrewer();
|
||||
ownedMixKeys.forEach(brewer::removePotionMix);
|
||||
ownedMixKeys.clear();
|
||||
}
|
||||
|
||||
private NamespacedKey mixKey(StaturePotion kind, Material target) {
|
||||
@@ -123,26 +190,36 @@ public final class PotionRecipes {
|
||||
return key("shifting_stature");
|
||||
}
|
||||
|
||||
private void registerShifting() {
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(shiftingKey(), key("growth"), key("diminution"), key("restoration"));
|
||||
}
|
||||
|
||||
private ShapedRecipe shiftingRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(shiftingKey(), create(StaturePotion.SHIFTING));
|
||||
recipe.shape(SHIFTING_SHAPE);
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('C', Material.CHORUS_FRUIT);
|
||||
recipe.setIngredient('W', Material.POTION);
|
||||
server.addRecipe(recipe);
|
||||
ItemStack awkward = items.apply(Material.POTION);
|
||||
PotionMeta awkwardMeta = (PotionMeta) awkward.getItemMeta();
|
||||
awkwardMeta.setBasePotionType(PotionType.AWKWARD);
|
||||
awkward.setItemMeta(awkwardMeta);
|
||||
recipe.setIngredient('W', new RecipeChoice.ExactChoice(awkward));
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerGrowth() {
|
||||
private ShapedRecipe growthRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("growth"), create(StaturePotion.GROWTH));
|
||||
recipe.shape(GROWTH_SHAPE);
|
||||
recipe.setIngredient('G', Material.GOLD_INGOT);
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('R', Material.RABBIT_FOOT);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerDiminution() {
|
||||
private ShapedRecipe diminutionRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("diminution"),
|
||||
create(StaturePotion.DIMINUTION));
|
||||
recipe.shape(DIMINUTION_SHAPE);
|
||||
@@ -150,10 +227,11 @@ public final class PotionRecipes {
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('F', Material.FERMENTED_SPIDER_EYE);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerRestoration() {
|
||||
private ShapedRecipe restorationRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("restoration"),
|
||||
create(StaturePotion.RESTORATION));
|
||||
recipe.shape(RESTORATION_SHAPE);
|
||||
@@ -161,7 +239,8 @@ public final class PotionRecipes {
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('U', Material.SUGAR);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static Color color(StaturePotion kind) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
public final class RecipeDiscovery implements Listener {
|
||||
private final List<NamespacedKey> recipeKeys;
|
||||
private final Logger logger;
|
||||
|
||||
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
|
||||
this.recipeKeys = List.copyOf(recipeKeys);
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public void discoverAll(Iterable<? extends Player> players) {
|
||||
players.forEach(this::discover);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
discover(event.getPlayer());
|
||||
}
|
||||
|
||||
public void discover(Player player) {
|
||||
try {
|
||||
List<NamespacedKey> unknown = recipeKeys.stream()
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (unknown.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
player.discoverRecipes(unknown);
|
||||
List<NamespacedKey> stillUnknown = unknown.stream()
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (!stillUnknown.isEmpty()) {
|
||||
logger.warning("Could not discover stature recipes for " + player.getName() + ": " + stillUnknown);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING, "Could not discover stature recipes for " + player.getName(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public final class RecipeLifecycle {
|
||||
private final Server server;
|
||||
private final Plugin plugin;
|
||||
private final PotionRecipes recipes;
|
||||
private final Logger logger;
|
||||
private RecipeDiscovery discovery;
|
||||
|
||||
public RecipeLifecycle(Server server, Plugin plugin, PotionRecipes recipes, Logger logger) {
|
||||
this.server = Objects.requireNonNull(server, "server");
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
this.recipes = Objects.requireNonNull(recipes, "recipes");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public boolean enable() {
|
||||
if (discovery != null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!recipes.register()) {
|
||||
logger.severe("Stature recipes were not registered as a complete group; discovery is disabled.");
|
||||
return false;
|
||||
}
|
||||
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
|
||||
discovery = new RecipeDiscovery(recipes.recipeKeys(), logger);
|
||||
server.getPluginManager().registerEvents(discovery, plugin);
|
||||
discovery.discoverAll(onlinePlayers);
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
unregisterDiscovery();
|
||||
try {
|
||||
recipes.unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
logger.log(Level.SEVERE,
|
||||
"Stature recipes were not registered as a complete group; discovery is disabled.", exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
unregisterDiscovery();
|
||||
recipes.unregister();
|
||||
}
|
||||
|
||||
private void unregisterDiscovery() {
|
||||
if (discovery != null) {
|
||||
HandlerList.unregisterAll(discovery);
|
||||
discovery = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.bukkit.plugin.java.JavaPlugin;
|
||||
public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
private BukkitStatureService stature;
|
||||
private PotionRecipes potions;
|
||||
private RecipeLifecycle recipeLifecycle;
|
||||
private BukkitCarry carry;
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -42,7 +43,8 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
command.setTabCompleter(executor);
|
||||
|
||||
potions = new PotionRecipes(this);
|
||||
potions.register();
|
||||
recipeLifecycle = new RecipeLifecycle(getServer(), this, potions, getLogger());
|
||||
recipeLifecycle.enable();
|
||||
getServer().getPluginManager().registerEvents(new StatureBrewing(potions), this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new StatureListener(potions, stature::drink, stature::resume,
|
||||
@@ -67,8 +69,8 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
if (stature != null) {
|
||||
stature.close();
|
||||
}
|
||||
if (potions != null) {
|
||||
potions.unregisterBrewing();
|
||||
if (recipeLifecycle != null) {
|
||||
recipeLifecycle.disable();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.BrewEvent;
|
||||
|
||||
/** Completes authenticated stature conversions at the native brewing transaction boundary. */
|
||||
/** Validates authenticated stature conversions at the native brewing transaction boundary. */
|
||||
public final class StatureBrewing implements Listener {
|
||||
private final PotionRecipes potions;
|
||||
|
||||
@@ -80,8 +80,8 @@ public final class StatureBrewing implements Listener {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
// 2618 recognizes custom recipes but mix() returns base-less potions unchanged.
|
||||
// Correct only that no-op; leave inventory and ingredient accounting to the server.
|
||||
// Correct only an unexpected native no-op; leave inventory and ingredient accounting
|
||||
// to the server. Build 2651 normally returns the registered native result directly.
|
||||
event.getResults().set(slot, potions.create(kind, target));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.item.alchemy.PotionBrewing;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.item.crafting.RecipeManager;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.BrewingStandBlockEntity;
|
||||
import org.bukkit.Bukkit;
|
||||
@@ -25,10 +26,11 @@ import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftBrewingRecipe;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryBrewer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.craftbukkit.util.CraftNamespacedKey;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
@@ -55,12 +57,12 @@ import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
|
||||
/** Real 2618 brewing ticks, items, metadata, recipes, event dispatch and ingredient consumption. */
|
||||
/** Real build-2651 brewing ticks, items, metadata, recipes, event dispatch and ingredient consumption. */
|
||||
class NativeBrewingTest {
|
||||
private static PluginManager events;
|
||||
private static Plugin plugin;
|
||||
private PotionRecipes recipes;
|
||||
private PotionBrewing engine;
|
||||
private RecipeManager engine;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
@@ -70,8 +72,8 @@ class NativeBrewingTest {
|
||||
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(server.getLogger()).thenReturn(Logger.getLogger("NativeBrewingTest"));
|
||||
when(server.getName()).thenReturn("NativeBrewingTest");
|
||||
when(server.getVersion()).thenReturn("26.2-2618");
|
||||
when(server.getBukkitVersion()).thenReturn("26.2-2618");
|
||||
when(server.getVersion()).thenReturn("26.3-2651");
|
||||
when(server.getBukkitVersion()).thenReturn("26.3-2651");
|
||||
when(server.isPrimaryThread()).thenReturn(true);
|
||||
events = new PaperPluginManagerImpl(server, new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
@@ -88,13 +90,23 @@ class NativeBrewingTest {
|
||||
@BeforeEach
|
||||
void registerActualRecipesAndListener() {
|
||||
HandlerList.unregisterAll();
|
||||
engine = PotionBrewing.bootstrap(FeatureFlags.VANILLA_SET);
|
||||
engine = NativeRuntime.recipes();
|
||||
recipes = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
PotionBrewer brewer = (PotionBrewer) Proxy.newProxyInstance(PotionBrewer.class.getClassLoader(),
|
||||
new Class<?>[] {PotionBrewer.class}, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "addPotionMix" -> engine.addPotionMix((io.papermc.paper.potion.PotionMix) args[0]);
|
||||
case "removePotionMix" -> engine.removePotionMix((NamespacedKey) args[0]);
|
||||
case "addPotionMix" -> {
|
||||
var mix = (io.papermc.paper.potion.PotionMix) args[0];
|
||||
var recipe = new org.bukkit.inventory.BrewingRecipe(
|
||||
mix.getKey(), mix.getResult(), mix.getInput(), mix.getIngredient());
|
||||
engine.recipes.addRecipe(CraftBrewingRecipe.fromBukkitRecipe(recipe).toMinecraftRecipe());
|
||||
engine.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
|
||||
}
|
||||
case "removePotionMix" -> {
|
||||
engine.recipes.removeRecipe(CraftNamespacedKey.toResourceKey(
|
||||
Registries.RECIPE, (NamespacedKey) args[0]));
|
||||
engine.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
return null;
|
||||
@@ -103,6 +115,20 @@ class NativeBrewingTest {
|
||||
events.registerEvents(new StatureBrewing(recipes), plugin);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(StaturePotion.class)
|
||||
void purpur26_3ConvertsBaseLessStaturePotionsWithoutTheBuild2618Correction(StaturePotion kind) {
|
||||
HandlerList.unregisterAll();
|
||||
NativeStand stand = new NativeStand();
|
||||
stand.inventory.setItem(0, recipes.create(kind));
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
ItemStack result = stand.inventory.getItem(0);
|
||||
assertEquals(Material.SPLASH_POTION, result.getType());
|
||||
assertEquals(kind, recipes.identify(result));
|
||||
assertTrue(stand.getItem(3).isEmpty());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(StaturePotion.class)
|
||||
void gunpowderActuallyConvertsEveryAuthenticatedDrinkable(StaturePotion kind) {
|
||||
@@ -127,7 +153,8 @@ class NativeBrewingTest {
|
||||
ItemStack result = stand.inventory.getItem(0);
|
||||
assertEquals(Material.LINGERING_POTION, result.getType());
|
||||
assertEquals(kind, recipes.identify(result));
|
||||
assertTrue(stand.getItem(3).isEmpty(), "one successful conversion spends one dragon's breath");
|
||||
assertEquals(new ItemStack(Material.GLASS_BOTTLE), stand.inventory.getIngredient(),
|
||||
"one successful conversion spends one dragon's breath and retains its bottle");
|
||||
assertLingeringApplies(result, kind);
|
||||
}
|
||||
|
||||
@@ -143,7 +170,7 @@ class NativeBrewingTest {
|
||||
stand.completeCycle();
|
||||
assertEquals(Material.LINGERING_POTION, stand.inventory.getItem(0).getType());
|
||||
assertEquals(kind, recipes.identify(stand.inventory.getItem(0)));
|
||||
assertTrue(stand.getItem(3).isEmpty());
|
||||
assertEquals(new ItemStack(Material.GLASS_BOTTLE), stand.inventory.getIngredient());
|
||||
assertEquals(18, stand.fuel);
|
||||
}
|
||||
|
||||
@@ -271,7 +298,8 @@ class NativeBrewingTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("conversionEdits")
|
||||
void inPlaceResultEditsCannotMutateInputOrBypassIngredientSafety(Material ingredient, boolean removeIdentity) {
|
||||
void nativeResultEditsRemainDetachedAndIdentityRemovalCannotSpendIngredients(
|
||||
Material ingredient, boolean removeIdentity) {
|
||||
events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL, (listener, event) -> {
|
||||
ItemStack result = ((BrewEvent) event).getResults().get(0);
|
||||
result.editMeta(meta -> {
|
||||
@@ -288,13 +316,27 @@ class NativeBrewingTest {
|
||||
stand.inventory.setItem(0, original);
|
||||
stand.inventory.setIngredient(new ItemStack(ingredient));
|
||||
stand.completeCycle();
|
||||
assertEquals(new ItemStack(ingredient), stand.inventory.getIngredient(),
|
||||
"in-place output conflicts cannot spend ingredients");
|
||||
assertEquals(original, stand.inventory.getItem(0), "cancelled result edits must not mutate the input inventory");
|
||||
if (removeIdentity) {
|
||||
assertEquals(new ItemStack(ingredient), stand.inventory.getIngredient(),
|
||||
"identity removal cannot spend ingredients");
|
||||
assertEquals(original, stand.inventory.getItem(0),
|
||||
"cancelled identity removal must not mutate the input inventory");
|
||||
} else {
|
||||
assertEquals(ingredient == Material.GUNPOWDER ? null : new ItemStack(Material.GLASS_BOTTLE),
|
||||
stand.inventory.getIngredient());
|
||||
ItemStack result = stand.inventory.getItem(0);
|
||||
assertEquals(ingredient == Material.GUNPOWDER ? Material.SPLASH_POTION : Material.LINGERING_POTION,
|
||||
result.getType());
|
||||
assertEquals(StaturePotion.GROWTH, recipes.identify(result));
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Other plugin's conflicting edit"),
|
||||
result.getItemMeta().displayName());
|
||||
assertEquals(StaturePotion.GROWTH, recipes.identify(original),
|
||||
"native result edits must stay detached from the input object");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultConstructionFailureDoesNotSpendTheIngredient() {
|
||||
void purpur26_3NativeOutputDoesNotNeedTheCorrectionFactory() {
|
||||
HandlerList.unregisterAll();
|
||||
PotionRecipes brokenFactory = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"),
|
||||
material -> { throw new IllegalStateException("simulated item factory failure"); });
|
||||
@@ -304,8 +346,9 @@ class NativeBrewingTest {
|
||||
stand.inventory.setItem(0, original);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
assertEquals(original, stand.inventory.getItem(0));
|
||||
assertEquals(new ItemStack(Material.GUNPOWDER), stand.inventory.getIngredient());
|
||||
assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(0).getType());
|
||||
assertEquals(StaturePotion.GROWTH, recipes.identify(stand.inventory.getItem(0)));
|
||||
assertTrue(stand.getItem(3).isEmpty());
|
||||
}
|
||||
|
||||
private void assertSplashApplies(ItemStack result, StaturePotion expected) {
|
||||
@@ -354,11 +397,11 @@ class NativeBrewingTest {
|
||||
|
||||
private final class NativeStand extends BrewingStandBlockEntity {
|
||||
private final CraftInventoryBrewer inventory = new CraftInventoryBrewer(this);
|
||||
private final Level level = mock(Level.class);
|
||||
private final ServerLevel level = mock(ServerLevel.class);
|
||||
|
||||
NativeStand() {
|
||||
super(BlockPos.ZERO, Blocks.BREWING_STAND.defaultBlockState());
|
||||
when(level.potionBrewing()).thenReturn(engine);
|
||||
when(level.recipeAccess()).thenReturn(engine);
|
||||
fuel = 20;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Actual Purpur 2618 listener discovery, distinct handler lists and Paper event dispatch.
|
||||
/** Actual Purpur build-2651 listener discovery, distinct handler lists and Paper event dispatch.
|
||||
* Only server/player boundaries are doubled; no listening server or client packets. */
|
||||
class NativeCarryRoutingTest {
|
||||
private static Server previousServer;
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.potion.PotionBrewer;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Pinned Purpur book, codec, placement and result-slot behavior with server/player boundaries doubled.
|
||||
* No listening server, client, CraftServer registry or CraftPlayer discovery bridge is started here. */
|
||||
class NativeRecipeBookTest {
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAwardsKnownHighlightsAndNotificationOnlyOnce() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
List<org.bukkit.event.Event> events = new ArrayList<>();
|
||||
PluginManager pluginManager = mock(PluginManager.class);
|
||||
doAnswer(invocation -> {
|
||||
events.add(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(pluginManager).callEvent(any());
|
||||
when(server.getPluginManager()).thenReturn(pluginManager);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
java.util.Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> nativeRecipes = new HashMap<>();
|
||||
List<net.minecraft.world.item.crafting.RecipeHolder<?>> holders = new ArrayList<>();
|
||||
for (Recipe recipe : registered) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) recipe;
|
||||
var key = nativeRecipeKey(bukkit.getKey());
|
||||
var nativeRecipe = nativeRecipe(bukkit);
|
||||
nativeRecipes.put(key, nativeRecipe);
|
||||
holders.add(new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe));
|
||||
}
|
||||
AtomicInteger displayId = new AtomicInteger();
|
||||
java.util.Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
Integer> displayIds = new HashMap<>();
|
||||
nativeRecipes.keySet().forEach(key -> displayIds.put(key, displayId.getAndIncrement()));
|
||||
TestServerRecipeBook book = new TestServerRecipeBook((key, consumer) -> {
|
||||
net.minecraft.world.item.crafting.ShapedRecipe recipe = nativeRecipes.get(key);
|
||||
consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(displayIds.get(key)),
|
||||
recipe.display().getFirst(), java.util.OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
java.util.Optional.of(recipe.placementInfo().ingredients())));
|
||||
});
|
||||
var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
|
||||
assertEquals(4, book.addRecipes(holders, nativePlayer));
|
||||
assertEquals(0, book.addRecipes(holders, nativePlayer));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()), book.known);
|
||||
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()),
|
||||
new LinkedHashSet<>(book.pack().highlight()));
|
||||
assertEquals(4, events.size());
|
||||
assertTrue(events.stream().allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::isInstance));
|
||||
assertTrue(events.stream().map(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::cast)
|
||||
.allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent::shouldShowNotification));
|
||||
|
||||
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
|
||||
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
|
||||
TestServerRecipeBook restored = new TestServerRecipeBook();
|
||||
restored.loadUntrusted(decoded, nativeRecipes::containsKey);
|
||||
assertEquals(book.known, restored.known);
|
||||
assertEquals(new LinkedHashSet<>(book.pack().highlight()),
|
||||
new LinkedHashSet<>(restored.pack().highlight()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookSerializationPreservesKnownHighlightsAndVanillaStateAcrossReenable() {
|
||||
List<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>> statureKeys =
|
||||
List.of("shifting_stature", "growth", "diminution", "restoration").stream()
|
||||
.map(value -> nativeRecipeKey(new NamespacedKey("spigotheights", value)))
|
||||
.toList();
|
||||
var vanillaKey = nativeRecipeKey(new NamespacedKey("minecraft", "stick"));
|
||||
TestServerRecipeBook original = new TestServerRecipeBook();
|
||||
original.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
original.add(vanillaKey);
|
||||
original.highlight(vanillaKey);
|
||||
statureKeys.forEach(key -> {
|
||||
original.add(key);
|
||||
original.highlight(key);
|
||||
});
|
||||
|
||||
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, original.pack()).getOrThrow();
|
||||
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
|
||||
TestServerRecipeBook restored = new TestServerRecipeBook();
|
||||
restored.loadUntrusted(decoded, key -> true);
|
||||
|
||||
assertTrue(restored.contains(vanillaKey));
|
||||
statureKeys.forEach(key -> assertTrue(restored.contains(key)));
|
||||
assertEquals(new LinkedHashSet<>(decoded.known()), restored.known);
|
||||
assertEquals(new LinkedHashSet<>(decoded.highlight()), new LinkedHashSet<>(restored.pack().highlight()));
|
||||
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
|
||||
int knownBeforeReenable = restored.known.size();
|
||||
int highlightsBeforeReenable = restored.pack().highlight().size();
|
||||
statureKeys.forEach(restored::add);
|
||||
assertEquals(knownBeforeReenable, restored.known.size());
|
||||
assertEquals(highlightsBeforeReenable, restored.pack().highlight().size(),
|
||||
"Re-enable must not manufacture another first-time highlight");
|
||||
assertTrue(restored.contains(vanillaKey));
|
||||
}
|
||||
|
||||
private static final class TestServerRecipeBook extends net.minecraft.stats.ServerRecipeBook {
|
||||
TestServerRecipeBook() {
|
||||
this((key, consumer) -> { });
|
||||
}
|
||||
|
||||
TestServerRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) {
|
||||
super(resolver);
|
||||
}
|
||||
|
||||
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) {
|
||||
highlight.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableAndReenableReRegistersRecipesWithoutForgettingPlayerKnowledge() {
|
||||
Server server = Bukkit.getServer();
|
||||
Set<NamespacedKey> registry = new LinkedHashSet<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation ->
|
||||
registry.add(((Keyed) invocation.getArgument(0)).getKey()));
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.remove(invocation.getArgument(0)));
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = discoveryPlayer("Persistent", known);
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
PotionRecipes firstRecipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle first = new RecipeLifecycle(server, plugin, firstRecipes, logger);
|
||||
|
||||
assertTrue(first.enable());
|
||||
first.disable();
|
||||
assertTrue(registry.isEmpty());
|
||||
assertEquals(new LinkedHashSet<>(firstRecipes.recipeKeys()), known);
|
||||
|
||||
PotionRecipes secondRecipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(new RecipeLifecycle(server, plugin, secondRecipes, logger).enable());
|
||||
assertEquals(new LinkedHashSet<>(secondRecipes.recipeKeys()), registry);
|
||||
verify(player, times(1)).discoverRecipes(any());
|
||||
verify(player, never()).undiscoverRecipes(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlineEnumerationFailureDoesNotLeaveDiscoveryListenerActiveAfterCleanup() {
|
||||
Server server = Bukkit.getServer();
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.getOnlinePlayers()).thenThrow(new IllegalStateException("simulated lifecycle failure"));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listenerRegistrationExceptionRemovesPartiallyRegisteredDiscoveryListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PluginManager registeredEvents = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
PluginManager throwingEvents = mock(PluginManager.class);
|
||||
doAnswer(invocation -> {
|
||||
registeredEvents.registerEvents(invocation.getArgument(0), invocation.getArgument(1));
|
||||
throw new IllegalStateException("simulated post-registration failure");
|
||||
}).when(throwingEvents).registerEvents(any(), any());
|
||||
when(server.getPluginManager()).thenReturn(throwingEvents);
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin("ListenerFailure");
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
registeredEvents.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty(), "A failed lifecycle must not leave its listener active");
|
||||
verify(server, times(4)).removeRecipe(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lifecycleRegistrationExceptionCleansOwnedRecipesLogsAndAwardsNobody() {
|
||||
Server server = Bukkit.getServer();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
if (attempts.getAndIncrement() == 1) {
|
||||
throw new IllegalStateException("simulated registry failure");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(discoveryPlayer("Online", known))).when(server).getOnlinePlayers();
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
List<String> logs = new ArrayList<>();
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record.getMessage()); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, logger);
|
||||
|
||||
boolean enabled = assertDoesNotThrow(lifecycle::enable);
|
||||
|
||||
assertFalse(enabled);
|
||||
assertEquals(List.of(recipes.shiftingKey()), removed);
|
||||
assertTrue(known.isEmpty());
|
||||
assertEquals(1, logs.size());
|
||||
verifyNoInteractions(events);
|
||||
verifyNoInteractions(brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupFailureIsLoggedWithoutEscapingOrActivatingDiscovery() {
|
||||
Server server = Bukkit.getServer();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
if (attempts.incrementAndGet() == 2) {
|
||||
throw new IllegalStateException("simulated registration failure");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class)))
|
||||
.thenThrow(new IllegalStateException("simulated cleanup failure"));
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
List<LogRecord> logs = new ArrayList<>();
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, mock(Plugin.class), recipes, logger);
|
||||
|
||||
boolean enabled = assertDoesNotThrow(lifecycle::enable);
|
||||
|
||||
assertFalse(enabled);
|
||||
assertEquals(1, logs.size());
|
||||
assertEquals(1, logs.getFirst().getThrown().getSuppressed().length);
|
||||
verifyNoInteractions(events, brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postInsertionRegistrationFailureRemovesEveryRecipeOwnedByThisLifecycle() {
|
||||
Server server = Bukkit.getServer();
|
||||
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
|
||||
java.util.Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
registry.put(unrelatedKey, mock(Recipe.class));
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(server.getRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.get(invocation.getArgument(0)));
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
registry.put(((Keyed) recipe).getKey(), recipe);
|
||||
if (attempts.incrementAndGet() == 2) {
|
||||
throw new IllegalStateException("simulated failure after native insertion");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.remove(invocation.getArgument(0)) != null);
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, logger);
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(unrelatedKey), registry.keySet());
|
||||
verifyNoInteractions(brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableUnregistersTheCurrentLifecycleDiscoveryListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin("DisableListener");
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
lifecycle.disable();
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulLifecycleDiscoversForOnlineAndLaterJoiningPlayersThroughRegisteredListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeRecipeBookTest"));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player online = discoveryPlayer("Online", onlineKnown);
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
doReturn(List.of(online)).when(server).getOnlinePlayers();
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(recipes.recipeKeys()), onlineKnown);
|
||||
assertEquals(new LinkedHashSet<>(recipes.recipeKeys()), joiningKnown);
|
||||
}
|
||||
|
||||
private static Plugin enabledPlugin(String loggerName) {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger(loggerName));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static Player discoveryPlayer(String name, Set<NamespacedKey> known) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getName()).thenReturn(name);
|
||||
when(player.hasDiscoveredRecipe(any(NamespacedKey.class)))
|
||||
.thenAnswer(invocation -> known.contains(invocation.getArgument(0)));
|
||||
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteRegistrationRollsBackOnlyRecipesAddedInThisAttempt() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<NamespacedKey> added = new ArrayList<>();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
NamespacedKey conflict = new NamespacedKey("spigotheights", "diminution");
|
||||
when(server.getRecipe(conflict)).thenReturn(mock(Recipe.class));
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
added.add(((Keyed) invocation.getArgument(0)).getKey());
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
assertFalse(recipes.register());
|
||||
|
||||
assertEquals(List.of(
|
||||
new NamespacedKey("spigotheights", "shifting_stature"),
|
||||
new NamespacedKey("spigotheights", "growth")), added);
|
||||
assertEquals(added, removed);
|
||||
assertFalse(removed.contains(conflict), "A pre-existing conflict is not owned by this lifecycle");
|
||||
verify(brewer, times(8)).addPotionMix(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesOnlySuccessfulCurrentLifecycleRegistrations() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<NamespacedKey> added = new ArrayList<>();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
added.add(((Keyed) invocation.getArgument(0)).getKey());
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
recipes.register();
|
||||
recipes.unregister();
|
||||
|
||||
assertEquals(4, added.size());
|
||||
assertEquals(added, removed);
|
||||
assertFalse(removed.contains(new NamespacedKey("minecraft", "unrelated")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAutofillPlacesExactInputsAndPreservesUnrelatedInventory() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
|
||||
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(nativePlayer.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(nativePlayer,
|
||||
new net.minecraft.world.entity.EntityEquipment());
|
||||
when(nativePlayer.getInventory()).thenReturn(inventory);
|
||||
List<ItemStack> expectedGrid = craftingGrid(recipes, index, null);
|
||||
for (int slot = 0; slot < expectedGrid.size(); slot++) {
|
||||
ItemStack ingredient = expectedGrid.get(slot);
|
||||
if (!ingredient.isEmpty()) {
|
||||
inventory.setItem(slot, CraftItemStack.asNMSCopy(ingredient));
|
||||
}
|
||||
}
|
||||
ItemStack unrelated = new ItemStack(Material.DIAMOND, 7);
|
||||
inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
|
||||
mock(net.minecraft.world.inventory.AbstractContainerMenu.class), 3, 3, nativePlayer);
|
||||
ItemStack displaced = new ItemStack(Material.DIRT, 3);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(displaced));
|
||||
List<net.minecraft.world.inventory.Slot> gridSlots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
|
||||
.toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override
|
||||
public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCraftingContent() {
|
||||
grid.clearContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return candidate.value().matches(
|
||||
net.minecraft.world.item.crafting.CraftingInput.of(3, 3, grid.getItems()),
|
||||
mock(net.minecraft.world.level.Level.class));
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3,
|
||||
gridSlots, gridSlots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(expectedGrid, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList());
|
||||
assertTrue(java.util.stream.IntStream.range(0, 9)
|
||||
.allMatch(slot -> inventory.getItem(slot).isEmpty()),
|
||||
"Autofill must relocate rather than duplicate or consume available ingredients");
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount)
|
||||
.sum(), "Autofill must return unrelated grid contents to inventory");
|
||||
assertTrue(nativeRecipe.matches(nativeInput(grid.getItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList()), mock(net.minecraft.world.level.Level.class)));
|
||||
net.minecraft.world.item.ItemStack nativeResult = nativeRecipe.assemble(nativeInput(
|
||||
grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList()));
|
||||
ItemStack result = CraftItemStack.asBukkitCopy(nativeResult);
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(result));
|
||||
assertEquals(1, result.getAmount());
|
||||
|
||||
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
|
||||
var resultContainer = new net.minecraft.world.inventory.ResultContainer();
|
||||
resultContainer.setRecipeUsed(holder);
|
||||
resultContainer.setItem(0, nativeResult);
|
||||
var resultSlot = new net.minecraft.world.inventory.ResultSlot(nativePlayer, grid,
|
||||
resultContainer, 0, 0, 0);
|
||||
net.minecraft.world.item.ItemStack taken = resultSlot.remove(1);
|
||||
resultSlot.onTake(nativePlayer, taken);
|
||||
|
||||
assertEquals(StaturePotion.values()[index],
|
||||
recipes.identify(CraftItemStack.asBukkitCopy(taken)));
|
||||
assertTrue(resultContainer.isEmpty(), "Completed crafting must yield exactly one result");
|
||||
assertTrue(grid.isEmpty(), "Completed crafting must consume exactly the placed ingredients");
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount)
|
||||
.sum());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAutofillRejectsInvalidPotionSubstitutionsWithoutMovingInventory() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
for (ItemStack invalidPotion : invalidPotionInputs(recipes, index)) {
|
||||
assertNativeAutofillRejected(recipes, index, bukkit, invalidPotion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNativeAutofillRejected(PotionRecipes recipes, int recipeIndex,
|
||||
ShapedRecipe bukkit, ItemStack invalidPotion) {
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
|
||||
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(nativePlayer,
|
||||
new net.minecraft.world.entity.EntityEquipment());
|
||||
List<ItemStack> available = craftingGrid(recipes, recipeIndex, invalidPotion);
|
||||
for (int slot = 0; slot < available.size(); slot++) {
|
||||
if (!available.get(slot).isEmpty()) {
|
||||
inventory.setItem(slot, CraftItemStack.asNMSCopy(available.get(slot)));
|
||||
}
|
||||
}
|
||||
inventory.setItem(20, CraftItemStack.asNMSCopy(new ItemStack(Material.DIAMOND, 7)));
|
||||
List<ItemStack> inventoryBefore = inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList();
|
||||
var grid = new net.minecraft.world.SimpleContainer(9);
|
||||
List<net.minecraft.world.inventory.Slot> gridSlots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
|
||||
.toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override
|
||||
public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCraftingContent() {
|
||||
grid.clearContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3,
|
||||
gridSlots, gridSlots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE,
|
||||
action, invalidPotion::toString);
|
||||
assertTrue(grid.isEmpty());
|
||||
assertEquals(inventoryBefore, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeRecipeKey(
|
||||
NamespacedKey key) {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeCraftingMatchesExactInputsAndProducesAllFourAuthenticatedResults() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
assertTrue(nativeRecipe.getIngredients().get(4).orElseThrow().isExact(),
|
||||
"Pinned recipe placement must retain the component-sensitive potion choice");
|
||||
List<ItemStack> valid = craftingGrid(recipes, index, null);
|
||||
List<ItemStack> unchanged = valid.stream().map(ItemStack::clone).toList();
|
||||
net.minecraft.world.item.crafting.CraftingInput input = nativeInput(valid);
|
||||
assertTrue(nativeRecipe.matches(input, mock(net.minecraft.world.level.Level.class)));
|
||||
ItemStack result = CraftItemStack.asBukkitCopy(nativeRecipe.assemble(input));
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(result));
|
||||
assertEquals(1, result.getAmount());
|
||||
assertEquals(unchanged, valid, "Matching and preview assembly must not consume the grid");
|
||||
|
||||
for (ItemStack invalidPotion : invalidPotionInputs(recipes, index)) {
|
||||
assertFalse(nativeRecipe.matches(nativeInput(craftingGrid(recipes, index, invalidPotion)),
|
||||
mock(net.minecraft.world.level.Level.class)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
java.util.Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) ->
|
||||
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)));
|
||||
var pattern = net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape());
|
||||
return new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
pattern, CraftItemStack.asTemplate(recipe.getResult()));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(List<ItemStack> items) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, CraftItemStack.asNMSCopy(items));
|
||||
}
|
||||
|
||||
private static List<ItemStack> craftingGrid(PotionRecipes recipes, int recipeIndex, ItemStack potionOverride) {
|
||||
String[] shape = switch (recipeIndex) {
|
||||
case 0 -> PotionRecipes.SHIFTING_SHAPE;
|
||||
case 1 -> PotionRecipes.GROWTH_SHAPE;
|
||||
case 2 -> PotionRecipes.DIMINUTION_SHAPE;
|
||||
case 3 -> PotionRecipes.RESTORATION_SHAPE;
|
||||
default -> throw new IllegalArgumentException("Unknown recipe index");
|
||||
};
|
||||
List<ItemStack> items = new ArrayList<>();
|
||||
for (String row : shape) {
|
||||
for (char symbol : row.toCharArray()) {
|
||||
items.add(switch (symbol) {
|
||||
case 'A' -> new ItemStack(Material.AMETHYST_SHARD);
|
||||
case 'C' -> new ItemStack(Material.CHORUS_FRUIT);
|
||||
case 'G' -> new ItemStack(Material.GOLD_INGOT);
|
||||
case 'R' -> new ItemStack(Material.RABBIT_FOOT);
|
||||
case 'F' -> new ItemStack(Material.FERMENTED_SPIDER_EYE);
|
||||
case 'U' -> new ItemStack(Material.SUGAR);
|
||||
case 'W', 'S' -> potionOverride != null ? potionOverride.clone()
|
||||
: recipeIndex == 0 ? awkwardPotion() : recipes.create(StaturePotion.SHIFTING);
|
||||
default -> ItemStack.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<ItemStack> invalidPotionInputs(PotionRecipes recipes, int recipeIndex) {
|
||||
ItemStack ordinary = new ItemStack(Material.POTION);
|
||||
ItemStack unrelated = new ItemStack(Material.POTION);
|
||||
unrelated.editMeta(org.bukkit.inventory.meta.PotionMeta.class,
|
||||
meta -> meta.setBasePotionType(org.bukkit.potion.PotionType.HEALING));
|
||||
if (recipeIndex == 0) {
|
||||
return List.of(ordinary, unrelated, recipes.create(StaturePotion.SHIFTING));
|
||||
}
|
||||
ItemStack nameOnly = new ItemStack(Material.POTION);
|
||||
nameOnly.editMeta(meta -> meta.displayName(
|
||||
net.kyori.adventure.text.Component.text(StaturePotion.SHIFTING.displayName())));
|
||||
return List.of(ordinary, unrelated, nameOnly, recipes.create(StaturePotion.GROWTH));
|
||||
}
|
||||
|
||||
private static ItemStack awkwardPotion() {
|
||||
ItemStack awkward = new ItemStack(Material.POTION);
|
||||
awkward.editMeta(org.bukkit.inventory.meta.PotionMeta.class,
|
||||
meta -> meta.setBasePotionType(org.bukkit.potion.PotionType.AWKWARD));
|
||||
return awkward;
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersAllAuthenticatedResultsInMiscWithExactPotionInputs() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
recipes.register();
|
||||
|
||||
assertEquals(4, registered.size());
|
||||
for (int index = 0; index < StaturePotion.values().length; index++) {
|
||||
ShapedRecipe recipe = assertInstanceOf(ShapedRecipe.class, registered.get(index));
|
||||
assertEquals(CraftingBookCategory.MISC, recipe.getCategory());
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(recipe.getResult()));
|
||||
RecipeChoice.ExactChoice choice = assertInstanceOf(RecipeChoice.ExactChoice.class,
|
||||
recipe.getChoiceMap().get(index == 0 ? 'W' : 'S'));
|
||||
ItemStack selected = choice.getChoices().getFirst();
|
||||
if (index == 0) {
|
||||
assertEquals(Material.POTION, selected.getType());
|
||||
assertTrue(recipes.isAwkwardPotion(selected));
|
||||
assertNull(recipes.identify(selected));
|
||||
} else {
|
||||
assertEquals(StaturePotion.SHIFTING, recipes.identify(selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,22 @@ import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.item.crafting.RecipeManager;
|
||||
import net.minecraft.world.level.DataPackConfig;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
|
||||
final class NativeRuntime {
|
||||
private static boolean bootstrapped;
|
||||
private static RecipeManager recipes;
|
||||
|
||||
private NativeRuntime() {}
|
||||
|
||||
static void bootstrap() throws Exception {
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (bootstrapped) {
|
||||
return;
|
||||
}
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
@@ -39,12 +46,12 @@ final class NativeRuntime {
|
||||
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
|
||||
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName(org.bukkit.Registry.class.getName());
|
||||
@@ -53,6 +60,16 @@ final class NativeRuntime {
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
recipes = datapack.getRecipeManager();
|
||||
recipes.finalizeRecipeLoading(flags);
|
||||
bootstrapped = true;
|
||||
}
|
||||
}
|
||||
|
||||
static RecipeManager recipes() {
|
||||
if (!bootstrapped) {
|
||||
throw new IllegalStateException("Native runtime has not been bootstrapped");
|
||||
}
|
||||
return recipes;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,12 @@ class PotionRecipesTest {
|
||||
input.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, kind.name());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(mix.getInput().test(input));
|
||||
org.junit.jupiter.api.Assertions.assertFalse(mix.getInput().test(mix.getResult()));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(mix.getIngredient().test(new BukkitDoubles.Item(
|
||||
var ingredient = org.junit.jupiter.api.Assertions.assertInstanceOf(
|
||||
org.bukkit.inventory.RecipeChoice.PredicateChoice.class, mix.getIngredient(),
|
||||
"Purpur 26.3 builds brewing reagent indexes from representative recipe choices");
|
||||
assertEquals(splash ? Material.GUNPOWDER : Material.DRAGON_BREATH,
|
||||
ingredient.getItemStack().getType());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(ingredient.test(new BukkitDoubles.Item(
|
||||
splash ? Material.GUNPOWDER : Material.DRAGON_BREATH)));
|
||||
org.junit.jupiter.api.Assertions.assertFalse(mix.getIngredient().test(new BukkitDoubles.Item(Material.SUGAR)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.jar.JarFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
class PurpurDistributionTest {
|
||||
@Test
|
||||
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
assertEquals("purpur-heights-" + version + ".jar", artifact.getFileName().toString());
|
||||
try (var jar = new JarFile(artifact.toFile())) {
|
||||
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals(version, metadata.get("version"));
|
||||
assertEquals("SpigotHeights", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals("games.dmg.spigotheights.SpigotHeightsPlugin", metadata.get("main"));
|
||||
assertEquals("1.21", metadata.get("api-version"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("commands")).containsKey("heights"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotheights.admin"));
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/")
|
||||
|| entry.getName().startsWith("net/minecraft/")
|
||||
|| entry.getName().startsWith("com/comphenix/protocol/")
|
||||
|| entry.getName().startsWith("org/junit/")
|
||||
|| entry.getName().startsWith("org/mockito/")
|
||||
|| entry.getName().endsWith("Test.class")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagesTheIntegratedRecipeDiscoveryLifecycle() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"))) {
|
||||
String plugin = classConstants(jar, "games/dmg/spigotheights/SpigotHeightsPlugin.class");
|
||||
String lifecycle = classConstants(jar, "games/dmg/spigotheights/RecipeLifecycle.class");
|
||||
String discovery = classConstants(jar, "games/dmg/spigotheights/RecipeDiscovery.class");
|
||||
String recipes = classConstants(jar, "games/dmg/spigotheights/PotionRecipes.class");
|
||||
assertTrue(plugin.contains("RecipeLifecycle"));
|
||||
assertTrue(lifecycle.contains("getOnlinePlayers"));
|
||||
assertTrue(lifecycle.contains("registerEvents"));
|
||||
assertTrue(discovery.contains("discoverRecipes"));
|
||||
assertTrue(discovery.contains("hasDiscoveredRecipe"));
|
||||
assertTrue(recipes.contains("CraftingBookCategory"));
|
||||
assertTrue(recipes.contains("AWKWARD"));
|
||||
assertTrue(recipes.contains("RecipeChoice$ExactChoice"));
|
||||
assertTrue(recipes.contains("removeRecipe"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String classConstants(JarFile jar, String entry) throws Exception {
|
||||
assertNotNull(jar.getJarEntry(entry), entry);
|
||||
return new String(jar.getInputStream(jar.getJarEntry(entry)).readAllBytes(), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginBytecodeTargetsJava25() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"));
|
||||
var bytecode = new DataInputStream(jar.getInputStream(
|
||||
jar.getJarEntry("games/dmg/spigotheights/SpigotHeightsPlugin.class")))) {
|
||||
assertEquals(0xCAFEBABE, bytecode.readInt());
|
||||
bytecode.readUnsignedShort();
|
||||
assertEquals(69, bytecode.readUnsignedShort());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAndCiAgreeWithThePurpur26_3DistributionAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
|
||||
assertTrue(build.contains("purpur-26.3-2651.jar"));
|
||||
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
|
||||
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
|
||||
assertFalse(build.contains("26.2") || build.contains("2618"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
|
||||
String content = Files.readString(Path.of(".gitea/workflows", workflow));
|
||||
assertTrue(content.contains("java-version: 25"));
|
||||
assertTrue(content.contains("purpur-heights-"), workflow);
|
||||
assertFalse(content.contains("spigot-heights-"), workflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RecipeDiscoveryTest {
|
||||
private final List<NamespacedKey> keys = List.of(
|
||||
new NamespacedKey("spigotheights", "shifting_stature"),
|
||||
new NamespacedKey("spigotheights", "growth"),
|
||||
new NamespacedKey("spigotheights", "diminution"),
|
||||
new NamespacedKey("spigotheights", "restoration"));
|
||||
|
||||
@Test
|
||||
void allAlreadyOnlinePlayersReceiveEveryUnknownRecipe() {
|
||||
Set<NamespacedKey> firstKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> secondKnown = new LinkedHashSet<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.discoverAll(List.of(
|
||||
player("First", firstKnown, new ArrayList<>(), false),
|
||||
player("Second", secondKnown, new ArrayList<>(), false)));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), firstKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), secondKnown);
|
||||
}
|
||||
|
||||
@Test
|
||||
void joiningPlayerReceivesEveryUnknownRecipe() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
Player player = player("Joiner", known, awards, false);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
|
||||
|
||||
assertEquals(List.of(keys), awards);
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayerExceptionIsLoggedWithoutBlockingOthersAndRetriesLater() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>();
|
||||
AtomicBoolean firstAttempt = new AtomicBoolean(true);
|
||||
List<String> warnings = new ArrayList<>();
|
||||
Logger logger = logger(warnings);
|
||||
Player failing = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getName" -> "Failing";
|
||||
case "hasDiscoveredRecipe" -> failedKnown.contains(args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
if (firstAttempt.getAndSet(false)) {
|
||||
throw new IllegalStateException("simulated discovery failure");
|
||||
}
|
||||
List<NamespacedKey> requested = ((Collection<?>) args[0]).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
failedKnown.addAll(requested);
|
||||
yield requested.size();
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger);
|
||||
|
||||
assertDoesNotThrow(() -> discovery.discover(failing));
|
||||
discovery.discover(player("Healthy", healthyKnown, new ArrayList<>(), false));
|
||||
discovery.discover(failing);
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), healthyKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), failedKnown);
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingPostconditionIsLoggedAndOnlyMissingRecipesRetryLater() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
Logger logger = logger(warnings);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger);
|
||||
|
||||
discovery.discover(player("Ada", known, awards, true));
|
||||
discovery.discover(player("Ada", known, awards, false));
|
||||
|
||||
assertEquals(List.of(keys, List.of(keys.getLast())), awards);
|
||||
assertEquals(1, warnings.size());
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void discoversOnlyUnknownRecipesAndRepeatedDiscoveryIsSilent() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
Player player = player("Ada", known, awards, false);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.discover(player);
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(List.of(keys), awards);
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
private static Logger logger(List<String> warnings) {
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { warnings.add(record.getMessage()); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
return logger;
|
||||
}
|
||||
|
||||
static Player player(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
|
||||
boolean omitLastAward) {
|
||||
return BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getName" -> name;
|
||||
case "hasDiscoveredRecipe" -> known.contains(args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) args[0]).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
awards.add(requested);
|
||||
int limit = omitLastAward ? Math.max(0, requested.size() - 1) : requested.size();
|
||||
known.addAll(requested.subList(0, limit));
|
||||
yield limit;
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user