Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0493bbf57 | ||
|
|
78271bd9d1 | ||
|
|
4bd6576bba | ||
|
|
e3e2c63048 | ||
|
|
6a4d0c530b | ||
|
|
7e60224096 |
No files matched your search
@@ -1,9 +1,13 @@
|
||||
# Purpur Ignis agent entrypoint
|
||||
|
||||
The canonical knowledge bundle is the sibling `../somc-okf/` repository, remotely at https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md.
|
||||
The canonical technical/process wiki is the sibling `../somc-okf/` repository, remotely at https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md.
|
||||
|
||||
Before changes read its root index, `processes/development.md`, `runbooks/development-cycle.md`, `projects/purpur-ignis/index.md`, `projects/purpur-ignis/engineering.md`, and the relevant `user-stories/purpur-ignis/` documents. Follow the parent workspace instructions. Do not create a local knowledge/design bundle.
|
||||
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-ignis] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
|
||||
|
||||
Before changes read its root index, `processes/development.md`, `runbooks/development-cycle.md`, `projects/purpur-ignis/index.md`, `projects/purpur-ignis/engineering.md`, and the relevant Kaneo tickets with subject prefix `[purpur-ignis]`. Follow the parent workspace instructions. Do not create a local knowledge/design bundle.
|
||||
|
||||
Use Java 25 and the declared Purpur API. Test behavior first with meaningful failures, implement minimally, then run `./gradlew clean check jar`. Never mark server/client behavior verified through domain tests alone.
|
||||
|
||||
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly.
|
||||
|
||||
Source/wiki publication, local-only GitOps preparation, and explicitly approved production release are separate boundaries. Never push GitOps during development. Do not expose credentials or place them in process arguments. Repository creation does not authorize credential provisioning.
|
||||
@@ -13,7 +13,7 @@ Requires Java 17+ to launch Gradle; the wrapper resolves a Java 25 toolchain for
|
||||
./gradlew clean jar -PreleaseVersion=0.1.0
|
||||
```
|
||||
|
||||
The runtime requires Java 25. New JARs are `build/libs/purpur-ignis-<version>.jar`. Targets Purpur `26.2.build.2618-stable` and requires **ProtocolLib 5.4.0** for Mask helmet projection. Neither server API nor ProtocolLib is bundled. Source builds do not require the private wiki.
|
||||
The runtime requires Java 25. New JARs are `build/libs/purpur-ignis-<version>.jar`. Targets Purpur `26.3.build.2651-stable` and requires **ProtocolLib 5.4.0** for Mask helmet projection. Neither server API nor ProtocolLib is bundled. Source builds do not require the private wiki.
|
||||
|
||||
## Knowledge and delivery
|
||||
|
||||
@@ -75,7 +75,7 @@ Right-click normally attempts **three blocks along the aim direction**. A visibl
|
||||
|
||||
A valid attempt starts a **two-second per-player cooldown**, shared across copies, even if unsafe or cancelled. Further attempts do not extend it. A blocked selected destination does not fall back to a different teleport. Item-use vetoes are respected, while vanilla's default denied block interaction with air does not suppress custom item use. Authenticated keys are prevented from being consumed as vanilla vault keys, including revoked copies.
|
||||
|
||||
Safety uses the player's current bounding box, a continuous sweep against block collision shapes (including neighboring protruding shapes), a supported exit, world/build boundaries, and conservative liquid/hazard rejection. It does not sample past thin walls or load missing chunks. Work is capped at 32,768 candidate block cells; extreme body sizes/routes can be refused. Unsupported mid-air exits and campfire/hazard surfaces are conservatively refused. Native `Player.teleport(..., PLUGIN)` preserves the normal protection/cancellation event path and leaves the reusable item intact. Actual terrain, scaled/posed player collisions, and protection-plugin behavior still require live tests.
|
||||
Safety uses the player's current bounding box, a continuous sweep against block collision shapes (including neighboring protruding shapes), world/build boundaries, and conservative liquid/hazard rejection. It does not sample past thin walls or load missing chunks. Work is capped at 32,768 candidate block cells; extreme body sizes/routes can be refused. Unsupported midair exits are allowed; hazardous surfaces, including campfires, remain refused. Grounded and airborne blinks neither require nor consume hunger and add no flight or fall immunity. Native `Player.teleport(..., PLUGIN)` preserves the normal protection/cancellation event path and leaves the reusable item intact. Actual terrain, scaled/posed player collisions, and protection-plugin behavior still require live tests.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -124,6 +124,8 @@ If loading fails, preserve the original file for investigation. Under an approve
|
||||
|
||||
JUnit tests exercise real temporary-file persistence, serialized writes, fault injection, threshold policies, receipt/owner/reset rules, challenge transitions, announcement components, command execution/completion, mocked Bukkit boundaries, metadata, and static workflow guardrails. YAML/shell parsing is not remote CI verification.
|
||||
|
||||
Native Dagger regressions exercise paid result-slot crafting, registered events, incoming use-item packet handling and CraftPlayer/native teleport state with outgoing position packets. They cover grounded and unsupported-air forward/target blinks with empty hunger, item retention, safety/protection refusals, receipts/resets and failed/cancelled cooldowns. The finite world, session/connection and visibility boundaries are fixtures, not a live server/client diagnosis.
|
||||
|
||||
Live Purpur startup/shutdown, event sequencing, item appearance/consumption, inventory durability across hard crashes, protections, and Stealth/client compatibility still require a manual smoke test. Minecraft inventory persistence and this plugin's state file are not a single atomic transaction: a hard crash between item delivery and Minecraft saving inventory needs operator recovery review. If disconnect/death happens during activation, the durable benefit can commit before the physical item is removed; any retained copy is inert, not another usable unlock. The approved lightweight contract guarantees one-item consumption in uninterrupted play and one permanent owner activation, not crash-atomic physical consumption. Hard crashes between the two saves may require administrator recovery; extra copies left by interruptions cannot activate again. No cross-save transaction protocol, forced player saves or autosave-length item restrictions are added. This is not a claim of live verification or permission to deploy.
|
||||
|
||||
Before a later approved production release, test: void damage/elytra/End Stone success and all interruption paths; other players and lookalikes failing activation; full inventory and failed saves; logout/death during activation; reset/re-earn invalidation; all advancement types and hover/click content with Stealth; admin permission/confirmation/offline UUID cases; direct-drink attribution after reconnect/restart and external effect replacement; skin outages; manual/shift/Crafter ingredient protection; tradable Mask issuer/holder resets; own/other-player helmet rendering, inventory interactions, and preservation of external invisibility; real consumed-pearl launch/statistic ordering; Dagger regular/shift/Crafter recipes, stale receipts, vault-key protection, target visibility, thin obstacles, scaled/posed bodies, hazardous/unsupported exits, world/chunk boundaries, cooldowns across copies/disconnect, and cancelled or redirected protection-plugin teleports.
|
||||
Before a later approved production release, test: void damage/elytra/End Stone success and all interruption paths; other players and lookalikes failing activation; full inventory and failed saves; logout/death during activation; reset/re-earn invalidation; all advancement types and hover/click content with Stealth; admin permission/confirmation/offline UUID cases; direct-drink attribution after reconnect/restart and external effect replacement; skin outages; manual/shift/Crafter ingredient protection; tradable Mask issuer/holder resets; own/other-player helmet rendering, inventory interactions, and preservation of external invisibility; real consumed-pearl launch/statistic ordering; Dagger regular/shift/Crafter recipes, stale receipts, vault-key protection, target visibility, thin obstacles, scaled/posed bodies, hazard refusals and allowed unsupported exits, world/chunk boundaries, cooldowns across copies/disconnect, and cancelled or redirected protection-plugin teleports.
|
||||
+6
-6
@@ -17,10 +17,10 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-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.mockito:mockito-core:5.18.0")
|
||||
@@ -36,24 +36,24 @@ tasks.test {
|
||||
// Native API/event/delivery regressions run without starting a listening server.
|
||||
val ignisRuntime = layout.buildDirectory.dir("ignis-runtime")
|
||||
val downloadIgnisRuntime = tasks.register("downloadIgnisRuntime") {
|
||||
val launcher = ignisRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||
val launcher = ignisRuntime.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)
|
||||
}
|
||||
}
|
||||
val prepareIgnisRuntime = tasks.register<JavaExec>("prepareIgnisRuntime") {
|
||||
dependsOn(downloadIgnisRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(ignisRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||
classpath = files(ignisRuntime.map { it.file("purpur-26.3-2651.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(ignisRuntime)
|
||||
|
||||
@@ -38,21 +38,22 @@ public final class DaggerSafety {
|
||||
if (!world.isChunkLoaded(x, z)) return false;
|
||||
}
|
||||
}
|
||||
var supportedBody = end.clone().shift(0, -.05, 0);
|
||||
// Keep rejecting hazardous surfaces just beneath the exit, but do not require ground.
|
||||
var nearFeetBody = end.clone().shift(0, -.05, 0);
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
var block = world.getBlockAt(x, y, z);
|
||||
var cell = new BoundingBox(x, y, z, x + 1, y + 1, z + 1);
|
||||
if ((block.isLiquid() || dangerous(block.getType()))
|
||||
&& (DaggerSweep.hits(body, motion, cell) || supportedBody.overlaps(cell))) return false;
|
||||
&& (DaggerSweep.hits(body, motion, cell) || nearFeetBody.overlaps(cell))) return false;
|
||||
for (var local : block.getCollisionShape().getBoundingBoxes()) {
|
||||
if (DaggerSweep.hits(body, motion, local.clone().shift(x, y, z))) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return player.wouldCollideUsing(supportedBody);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean dangerous(Material material) {
|
||||
|
||||
@@ -29,9 +29,9 @@ class AnnouncementPluginLifecycleTest {
|
||||
var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "isPrimaryThread" -> true;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
|
||||
case "getMinecraftVersion" -> "26.3";
|
||||
case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
})) {
|
||||
var packets = new ArrayList<PacketListener>();
|
||||
@@ -68,6 +68,8 @@ class AnnouncementPluginLifecycleTest {
|
||||
assertNotNull(callback);
|
||||
callback.run();
|
||||
assertTrue(events.stream().anyMatch(AnnouncementListener.class::isInstance));
|
||||
assertTrue(events.stream().anyMatch(DaggerCrafting.class::isInstance), "actual startup must register paid Dagger crafting");
|
||||
assertTrue(events.stream().anyMatch(DaggerUse.class::isInstance), "actual startup must register Dagger right-click handling");
|
||||
assertTrue(packets.stream().anyMatch(AnnouncementPacketListener.class::isInstance), "native delivery backstop must be registered by the real plugin");
|
||||
verify(server, never()).createPlayerProfile(anyString());
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.nio.file.Path;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class NativeDaggerBlinkTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void craftedDaggerBlinksThroughNativeInteractionAndMovementOnGroundOrMidair(boolean grounded) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.floor = grounded;
|
||||
double y = grounded ? 64 : 80;
|
||||
f.position(8, y, 8, 0, 0);
|
||||
var dagger = f.craft().clone();
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, y, 11), f.nativePlayer.position(), "native crafted-item activation must move the player");
|
||||
assertEquals(1, f.packets.size(), "actual native teleport dispatch must emit a position packet");
|
||||
assertEquals(f.nativePlayer.position(), f.packets.getFirst().change().position());
|
||||
assertEquals(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN, f.teleports.getFirst().getCause());
|
||||
assertEquals(dagger, f.inventory.getItem(0), "the actual crafted item is reusable");
|
||||
assertEquals(0, f.player.getFoodLevel());
|
||||
assertEquals(.5f, f.food.exhaustionLevel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"0,0", "45,-30", "-90,30", "0,-90", "0,90"})
|
||||
void airbornePacketActivationFollowsThreeDimensionalAimWithoutFlightOrFallImmunityOrHungerCost(float yaw, float pitch) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.floor = false;
|
||||
f.position(8, 80, 8, yaw, pitch);
|
||||
f.craft();
|
||||
f.nativePlayer.fallDistance = 7;
|
||||
var expected = f.player.getLocation().toVector().add(f.player.getEyeLocation().getDirection().multiply(3));
|
||||
f.usePacket();
|
||||
assertEquals(expected.getX(), f.nativePlayer.position().x, 1e-10);
|
||||
assertEquals(expected.getY(), f.nativePlayer.position().y, 1e-10);
|
||||
assertEquals(expected.getZ(), f.nativePlayer.position().z, 1e-10);
|
||||
assertEquals(yaw, f.nativePlayer.getYRot());
|
||||
assertEquals(pitch, f.nativePlayer.getXRot());
|
||||
assertEquals(7, f.nativePlayer.fallDistance);
|
||||
assertEquals(0, f.food.getFoodLevel());
|
||||
assertEquals(.5f, f.food.exhaustionLevel);
|
||||
verify(f.player, never()).setAllowFlight(anyBoolean());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void nativePlayerTargetBlinkWorksOnGroundOrInAirAndRetainsLook(boolean grounded) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.floor = grounded;
|
||||
double y = grounded ? 64 : 80;
|
||||
f.position(8, y, 8, 8, -4);
|
||||
var dagger = f.craft().clone();
|
||||
f.target(8, y, 13, 0);
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, y, 11.5), f.nativePlayer.position());
|
||||
assertEquals(8, f.nativePlayer.getYRot());
|
||||
assertEquals(-4, f.nativePlayer.getXRot());
|
||||
assertEquals(dagger, f.inventory.getItem(0));
|
||||
assertEquals(0, f.food.getFoodLevel());
|
||||
assertEquals(1, f.teleports.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void nativeVaultInteractionsCannotConsumeIssuedOrRevokedDaggers(boolean revoked) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var dagger = f.craft().clone();
|
||||
if (revoked) f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.DAGGER))).get();
|
||||
f.blocks.put(new BlockPos(8, 64, 9), Blocks.VAULT.defaultBlockState());
|
||||
var event = org.bukkit.craftbukkit.event.CraftEventFactory.callPlayerInteractEvent(f.nativePlayer,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK, new BlockPos(8, 64, 9), net.minecraft.core.Direction.NORTH,
|
||||
f.nativeInventory.getItem(0), net.minecraft.world.InteractionHand.MAIN_HAND);
|
||||
assertEquals(org.bukkit.Material.VAULT, event.getClickedBlock().getType());
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useInteractedBlock());
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
|
||||
assertEquals(dagger, f.inventory.getItem(0));
|
||||
assertTrue(f.packets.isEmpty());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"thin-wall", "hazard", "hazard-surface", "liquid", "unloaded", "border", "ceiling", "bottom", "oversized"})
|
||||
void nativeSafetyRefusalsLeaveThePaidItemAndPositionUntouched(String reason) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var dagger = f.craft().clone();
|
||||
var border = f.world.getWorldBorder();
|
||||
switch (reason) {
|
||||
case "thin-wall" -> f.blocks.put(new BlockPos(8, 64, 9), Blocks.OAK_TRAPDOOR.defaultBlockState()
|
||||
.setValue(net.minecraft.world.level.block.TrapDoorBlock.OPEN, true));
|
||||
case "hazard" -> f.blocks.put(new BlockPos(8, 64, 11), Blocks.FIRE.defaultBlockState());
|
||||
case "hazard-surface" -> f.blocks.put(new BlockPos(8, 63, 11), Blocks.MAGMA_BLOCK.defaultBlockState());
|
||||
case "liquid" -> f.blocks.put(new BlockPos(8, 64, 11), Blocks.WATER.defaultBlockState());
|
||||
case "unloaded" -> when(f.world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
case "border" -> when(border.isInside(any())).thenReturn(false);
|
||||
case "ceiling" -> f.position(8, 319, 8, 0, 0);
|
||||
case "bottom" -> f.position(8, -63, 8, 0, 90);
|
||||
case "oversized" -> f.nativePlayer.setBoundingBox(new net.minecraft.world.phys.AABB(-24, 64, -24, 40, 128, 40));
|
||||
default -> fail(reason);
|
||||
}
|
||||
var origin = f.nativePlayer.position();
|
||||
clearInvocations(f.world);
|
||||
f.usePacket();
|
||||
assertEquals(origin, f.nativePlayer.position());
|
||||
assertTrue(f.packets.isEmpty());
|
||||
assertTrue(f.teleports.isEmpty(), "unsafe attempts must never reach native teleport dispatch");
|
||||
assertEquals(dagger, f.inventory.getItem(0));
|
||||
if (reason.equals("unloaded") || reason.equals("oversized")) {
|
||||
verify(f.world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"item-veto", "block-veto", "teleport-cancelled", "reset", "reset-reearned", "unknown-receipt", "locked-holder", "forged-name"})
|
||||
void nativeProtectionAndReceiptBoundariesCannotBeBypassed(String reason) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.craft();
|
||||
switch (reason) {
|
||||
case "item-veto" -> f.beforeEvent = event -> {
|
||||
if (event instanceof org.bukkit.event.player.PlayerInteractEvent use) use.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
};
|
||||
case "block-veto" -> f.beforeEvent = event -> {
|
||||
if (event instanceof org.bukkit.event.player.PlayerInteractEvent use) use.setUseInteractedBlock(org.bukkit.event.Event.Result.DENY);
|
||||
};
|
||||
case "teleport-cancelled" -> f.cancelTeleport = true;
|
||||
case "reset", "reset-reearned" -> {
|
||||
f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.DAGGER))).get();
|
||||
if (reason.equals("reset-reearned")) {
|
||||
f.states.update(state -> ProgressAdministration.progress(state, f.owner, Track.DAGGER, 313, false)).get();
|
||||
}
|
||||
}
|
||||
case "unknown-receipt" -> f.inventory.setItem(0, f.items.create(new ItemToken(f.owner, java.util.UUID.randomUUID(), 0)));
|
||||
case "locked-holder" -> when(f.player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
case "forged-name" -> f.inventory.setItem(0, f.items.prototype());
|
||||
default -> fail(reason);
|
||||
}
|
||||
var item = f.inventory.getItem(0).clone();
|
||||
var origin = f.nativePlayer.position();
|
||||
if (reason.equals("block-veto")) {
|
||||
org.bukkit.craftbukkit.event.CraftEventFactory.callPlayerInteractEvent(f.nativePlayer,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK, new BlockPos(8, 63, 8), net.minecraft.core.Direction.UP,
|
||||
f.nativeInventory.getItem(0), net.minecraft.world.InteractionHand.MAIN_HAND);
|
||||
} else {
|
||||
f.use(); // Actual native event factory; forged keys retain vanilla's unrelated item path.
|
||||
}
|
||||
assertEquals(origin, f.nativePlayer.position());
|
||||
assertEquals(item, f.inventory.getItem(0));
|
||||
assertTrue(f.packets.isEmpty());
|
||||
assertEquals(reason.equals("teleport-cancelled") ? 1 : 0, f.teleports.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"copies", "unsafe", "cancelled"})
|
||||
void sharedCooldownSurvivesCopiesFailuresAndNativeCancellationWithoutSpamExtension(String mode) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var copy = f.craft().clone();
|
||||
if (mode.equals("unsafe")) f.blocks.put(new BlockPos(8, 64, 9), Blocks.STONE.defaultBlockState());
|
||||
if (mode.equals("cancelled")) f.cancelTeleport = true;
|
||||
f.usePacket();
|
||||
int first = mode.equals("copies") ? 1 : 0;
|
||||
assertEquals(first, f.packets.size());
|
||||
f.inventory.setItem(0, copy.clone());
|
||||
f.blocks.clear();
|
||||
f.cancelTeleport = false;
|
||||
f.clock.set(1_999_999_999L);
|
||||
f.usePacket();
|
||||
assertEquals(first, f.packets.size());
|
||||
f.clock.set(2_000_000_000L);
|
||||
f.usePacket();
|
||||
assertEquals(first + 1, f.packets.size());
|
||||
assertEquals(copy, f.inventory.getItem(0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void blockedSelectedTargetDoesNotFallBackToAnOtherwiseSafeForwardBlink() throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.craft();
|
||||
f.target(8, 64, 13, 180);
|
||||
f.blocks.put(new BlockPos(8, 64, 14), Blocks.STONE.defaultBlockState());
|
||||
assertTrue(DaggerSafety.safe(f.player, new org.bukkit.Location(f.world, 8, 64, 11)));
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, 64, 8), f.nativePlayer.position());
|
||||
assertTrue(f.packets.isEmpty());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"invisible", "vanished", "dead", "spectator", "no-line-of-sight", "out-of-range", "outside-cone", "unloaded-corridor"})
|
||||
void ineligibleTargetsLeaveTheNormalForwardBlinkAvailable(String reason) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
f.craft();
|
||||
double x = reason.equals("outside-cone") ? 13 : 8;
|
||||
double z = reason.equals("out-of-range") ? 17 : reason.equals("unloaded-corridor") ? 16 : 13;
|
||||
var target = f.target(x, 64, z, 0);
|
||||
switch (reason) {
|
||||
case "invisible" -> when(target.isInvisible()).thenReturn(true);
|
||||
case "vanished" -> when(f.player.canSee(target)).thenReturn(false);
|
||||
case "dead" -> when(target.isDead()).thenReturn(true);
|
||||
case "spectator" -> when(target.getGameMode()).thenReturn(org.bukkit.GameMode.SPECTATOR);
|
||||
case "no-line-of-sight" -> when(f.player.hasLineOfSight(target)).thenReturn(false);
|
||||
case "unloaded-corridor" -> when(f.world.isChunkLoaded(0, 1)).thenReturn(false);
|
||||
case "out-of-range", "outside-cone" -> { }
|
||||
default -> fail(reason);
|
||||
}
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, 64, 11), f.nativePlayer.position());
|
||||
if (reason.equals("out-of-range") || reason.equals("unloaded-corridor")) {
|
||||
verify(f.player, never()).hasLineOfSight(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void unlockedRecipientsCanUseTradedCopiesButIssuerResetStillRevokesThem(boolean resetIssuer) throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var copy = f.craft().clone();
|
||||
var recipient = java.util.UUID.randomUUID();
|
||||
when(f.player.getUniqueId()).thenReturn(recipient);
|
||||
f.states.update(state -> ProgressAdministration.progress(state, recipient, Track.DAGGER, 313, false)).get();
|
||||
if (resetIssuer) {
|
||||
f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.DAGGER))).get();
|
||||
f.states.update(state -> ProgressAdministration.progress(state, f.owner, Track.DAGGER, 313, false)).get();
|
||||
}
|
||||
f.usePacket();
|
||||
assertEquals(new Vec3(8, 64, resetIssuer ? 8 : 11), f.nativePlayer.position());
|
||||
assertEquals(copy, f.inventory.getItem(0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void actualOffhandPacketCanUseTheReusableCraftedDagger() throws Exception {
|
||||
NativeDaggerFixture.onTick(() -> {
|
||||
try (var f = new NativeDaggerFixture(directory)) {
|
||||
var dagger = f.craft().clone();
|
||||
f.inventory.setItem(0, null);
|
||||
f.inventory.setItem(40, dagger);
|
||||
f.usePacket(net.minecraft.world.InteractionHand.OFF_HAND);
|
||||
assertEquals(new Vec3(8, 64, 11), f.nativePlayer.position());
|
||||
assertEquals(dagger, f.inventory.getItem(40));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import ca.spottedleaf.moonrise.common.util.TickThread;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.PositionMoveRotation;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.inventory.CraftingMenu;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.portal.TeleportTransition;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.craftbukkit.CraftServer;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.bukkit.craftbukkit.block.CraftBlock;
|
||||
import org.bukkit.craftbukkit.entity.CraftEntity;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.event.CraftEventFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.EventExecutor;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.plugin.RegisteredListener;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Native items, recipe/result slots, interaction events, block shapes and teleport/position packet path.
|
||||
* Server/session, scheduler/lookup, visibility and finite-world queries are modeled ports;
|
||||
* selected native methods run unchanged, with real temporary-file persistence. */
|
||||
final class NativeDaggerFixture implements AutoCloseable {
|
||||
final UUID owner = UUID.randomUUID();
|
||||
final CraftServer server = mock(CraftServer.class);
|
||||
final JavaPlugin plugin = mock(JavaPlugin.class);
|
||||
final CraftWorld world = mock(CraftWorld.class);
|
||||
final ServerLevel level = mock(ServerLevel.class);
|
||||
final CraftPlayer player = mock(CraftPlayer.class, call -> call.getMethod().getName().equals("teleport0")
|
||||
? call.callRealMethod() : org.mockito.Answers.RETURNS_DEFAULTS.answer(call));
|
||||
final ServerPlayer nativePlayer = mock(ServerPlayer.class, call -> java.util.Set.of("makeBoundingBox", "reapplyPosition").contains(call.getMethod().getName())
|
||||
? call.callRealMethod() : org.mockito.Answers.RETURNS_DEFAULTS.answer(call));
|
||||
final ServerGamePacketListenerImpl connection = mock(ServerGamePacketListenerImpl.class);
|
||||
final PluginManager events = mock(PluginManager.class);
|
||||
final org.mockito.MockedStatic<Bukkit> platform;
|
||||
final org.mockito.MockedStatic<io.papermc.paper.configuration.GlobalConfiguration> settings;
|
||||
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
|
||||
final AtomicLong clock = new AtomicLong();
|
||||
final Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
final List<org.bukkit.entity.Player> players = new ArrayList<>();
|
||||
final List<ClientboundPlayerPositionPacket> packets = new ArrayList<>();
|
||||
final List<PlayerTeleportEvent> teleports = new ArrayList<>();
|
||||
final LinkedBlockingQueue<Runnable> callbacks = new LinkedBlockingQueue<>();
|
||||
final DaggerItems items = new DaggerItems();
|
||||
final CraftInventoryPlayer inventory;
|
||||
final Inventory nativeInventory;
|
||||
final CraftingMenu menu;
|
||||
final StateService states;
|
||||
final DaggerCrafting crafting;
|
||||
ShapedRecipe recipe;
|
||||
boolean floor = true, cancelTeleport;
|
||||
java.util.function.Consumer<Event> beforeEvent = ignored -> { };
|
||||
|
||||
NativeDaggerFixture(Path directory) throws Exception {
|
||||
assertTrue(TickThread.isTickThread(), "execute world/movement work on a real native tick thread");
|
||||
platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "getPluginManager" -> events;
|
||||
case "getItemFactory" -> CraftItemFactory.instance();
|
||||
case "getUnsafe" -> CraftMagicNumbers.INSTANCE;
|
||||
case "isPrimaryThread" -> TickThread.isTickThread();
|
||||
default -> call.callRealMethod();
|
||||
});
|
||||
var configuration = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
configuration.spamLimiter = configuration.new SpamLimiter(); // Native defaults; no packet-limit bypass.
|
||||
settings = mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class);
|
||||
settings.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(configuration);
|
||||
when(server.getItemFactory()).thenReturn(CraftItemFactory.instance());
|
||||
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.getPlayer(owner)).thenReturn(player);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotIgnis");
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
when(player.getServer()).thenReturn(server);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getHandle()).thenReturn(nativePlayer);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
when(player.getEyeHeight()).thenReturn(1.62);
|
||||
when(player.canSee(any(org.bukkit.entity.Player.class))).thenReturn(true);
|
||||
when(player.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(nativePlayer.level()).thenReturn(level);
|
||||
food.setFoodLevel(0);
|
||||
food.setSaturation(0);
|
||||
food.exhaustionLevel = .5f;
|
||||
when(nativePlayer.getFoodData()).thenReturn(food);
|
||||
doCallRealMethod().when(player).getFoodLevel();
|
||||
var gameServer = mock(net.minecraft.server.MinecraftServer.class);
|
||||
var processor = new net.minecraft.network.PacketProcessor(Thread.currentThread());
|
||||
when(gameServer.packetProcessor()).thenReturn(processor);
|
||||
when(level.getServer()).thenReturn(gameServer);
|
||||
set(net.minecraft.world.level.Level.class, level, "purpurConfig", mock(org.purpurmc.purpur.PurpurWorldConfig.class));
|
||||
set(ServerPlayer.class, nativePlayer, "gameMode", new net.minecraft.server.level.ServerPlayerGameMode(nativePlayer));
|
||||
when(level.getWorld()).thenReturn(world);
|
||||
when(level.dimension()).thenReturn(net.minecraft.world.level.Level.OVERWORLD);
|
||||
when(level.getTypeKey()).thenReturn(net.minecraft.world.level.dimension.LevelStem.OVERWORLD);
|
||||
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
when(world.getMinHeight()).thenReturn(-64);
|
||||
when(world.getMaxHeight()).thenReturn(320);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(world.getPlayers()).thenReturn(players);
|
||||
players.add(player);
|
||||
var border = mock(org.bukkit.WorldBorder.class);
|
||||
when(world.getWorldBorder()).thenReturn(border);
|
||||
when(border.isInside(any(Location.class))).thenReturn(true);
|
||||
when(level.getBlockState(any(BlockPos.class))).thenAnswer(call -> {
|
||||
var pos = call.getArgument(0, BlockPos.class);
|
||||
return blocks.getOrDefault(pos, floor && pos.getY() == 63 ? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState());
|
||||
});
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(call ->
|
||||
CraftBlock.at(level, new BlockPos(call.getArgument(0, Integer.class), call.getArgument(1, Integer.class), call.getArgument(2, Integer.class))));
|
||||
// Keep the pre-fix supported-exit port for regression replay, using native block shapes.
|
||||
when(level.noCollision(eq(nativePlayer), any(net.minecraft.world.phys.AABB.class))).thenAnswer(call -> {
|
||||
var body = call.getArgument(1, net.minecraft.world.phys.AABB.class);
|
||||
for (int x = (int) Math.floor(body.minX); x <= Math.floor(body.maxX); x++) {
|
||||
for (int y = (int) Math.floor(body.minY); y <= Math.floor(body.maxY); y++) {
|
||||
for (int z = (int) Math.floor(body.minZ); z <= Math.floor(body.maxZ); z++) {
|
||||
var pos = new BlockPos(x, y, z);
|
||||
for (var box : level.getBlockState(pos).getCollisionShape(level, pos).toAabbs()) {
|
||||
if (box.move(x, y, z).intersects(body)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
seedPosition(nativePlayer, new Vec3(8, 64, 8));
|
||||
set(CraftEntity.class, player, "entity", nativePlayer);
|
||||
doReturn(EntityTypes.PLAYER).when(nativePlayer).getType();
|
||||
doCallRealMethod().when(nativePlayer).position();
|
||||
doCallRealMethod().when(nativePlayer).getX();
|
||||
doCallRealMethod().when(nativePlayer).getY();
|
||||
doCallRealMethod().when(nativePlayer).getZ();
|
||||
doCallRealMethod().when(nativePlayer).getYRot();
|
||||
doCallRealMethod().when(nativePlayer).getYHeadRot();
|
||||
doCallRealMethod().when(nativePlayer).setYHeadRot(anyFloat());
|
||||
doCallRealMethod().when(nativePlayer).getXRot();
|
||||
doCallRealMethod().when(nativePlayer).getBukkitYaw();
|
||||
doCallRealMethod().when(nativePlayer).getBoundingBox();
|
||||
when(nativePlayer.getDeltaMovement()).thenReturn(Vec3.ZERO);
|
||||
when(nativePlayer.getKnownMovement()).thenReturn(Vec3.ZERO);
|
||||
when(nativePlayer.getClientPositionAndRotation()).thenAnswer(call ->
|
||||
net.minecraft.core.PositionAndRotation.of(nativePlayer.position(), nativePlayer.getYRot(), nativePlayer.getXRot()));
|
||||
doCallRealMethod().when(nativePlayer).setPos(anyDouble(), anyDouble(), anyDouble());
|
||||
doCallRealMethod().when(nativePlayer).setBoundingBox(any(net.minecraft.world.phys.AABB.class));
|
||||
doCallRealMethod().when(nativePlayer).setPosRaw(anyDouble(), anyDouble(), anyDouble());
|
||||
doCallRealMethod().when(nativePlayer).setPosRaw(anyDouble(), anyDouble(), anyDouble(), anyBoolean());
|
||||
doCallRealMethod().when(nativePlayer).setYRot(anyFloat());
|
||||
doCallRealMethod().when(nativePlayer).setXRot(anyFloat());
|
||||
doCallRealMethod().when(nativePlayer).teleportSetPosition(any(PositionMoveRotation.class), anySet());
|
||||
doCallRealMethod().when(nativePlayer).teleportSetPosition(any(PositionMoveRotation.class), any(PositionMoveRotation.class), anySet());
|
||||
doCallRealMethod().when(nativePlayer).teleport(any(TeleportTransition.class));
|
||||
when(nativePlayer.isAlive()).thenReturn(true);
|
||||
nativePlayer.valid = true;
|
||||
nativePlayer.connection = connection;
|
||||
connection.player = nativePlayer;
|
||||
when(connection.hasClientLoaded()).thenReturn(true);
|
||||
doCallRealMethod().when(connection).handleUseItem(any(net.minecraft.network.protocol.game.ServerboundUseItemPacket.class));
|
||||
when(nativePlayer.getEyePosition()).thenAnswer(call -> org.bukkit.craftbukkit.util.CraftVector.toVec3(player.getEyeLocation().toVector()));
|
||||
when(nativePlayer.getViewVector(anyFloat())).thenAnswer(call -> org.bukkit.craftbukkit.util.CraftVector.toVec3(player.getEyeLocation().getDirection()));
|
||||
when(nativePlayer.blockInteractionRange()).thenReturn(4.5);
|
||||
// The finite world supplies the incoming ray result; block collision safety uses actual CraftBlock shapes.
|
||||
when(level.clip(any(net.minecraft.world.level.ClipContext.class))).thenAnswer(call -> {
|
||||
var to = call.getArgument(0, net.minecraft.world.level.ClipContext.class).getTo();
|
||||
return net.minecraft.world.phys.BlockHitResult.miss(to, net.minecraft.core.Direction.SOUTH, BlockPos.containing(to));
|
||||
});
|
||||
doCallRealMethod().when(connection).internalTeleport(any(PositionMoveRotation.class), anySet());
|
||||
doAnswer(call -> { packets.add(call.getArgument(0)); return null; }).when(connection).send(any(ClientboundPlayerPositionPacket.class));
|
||||
doCallRealMethod().when(player).getLocation();
|
||||
doCallRealMethod().when(player).getEyeLocation();
|
||||
doCallRealMethod().when(player).getBoundingBox();
|
||||
doCallRealMethod().when(player).wouldCollideUsing(any(org.bukkit.util.BoundingBox.class));
|
||||
doCallRealMethod().when(player).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class));
|
||||
doCallRealMethod().when(player).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class), any(io.papermc.paper.entity.TeleportFlag[].class));
|
||||
nativeInventory = new Inventory(nativePlayer, new EntityEquipment());
|
||||
when(nativePlayer.getInventory()).thenReturn(nativeInventory);
|
||||
when(nativePlayer.getItemInHand(any(InteractionHand.class))).thenAnswer(call ->
|
||||
nativeInventory.getItem(call.getArgument(0) == InteractionHand.MAIN_HAND ? 0 : 40));
|
||||
inventory = new CraftInventoryPlayer(nativeInventory);
|
||||
set(net.minecraft.world.entity.player.Player.class, nativePlayer, "inventoryMenu",
|
||||
new net.minecraft.world.inventory.InventoryMenu(nativeInventory, false, nativePlayer));
|
||||
nativePlayer.containerMenu = nativePlayer.inventoryMenu;
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
menu = new CraftingMenu(0, nativeInventory);
|
||||
var craftingView = menu.getBukkitView();
|
||||
when(player.getOpenInventory()).thenReturn(craftingView);
|
||||
var scheduler = mock(CraftScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { callbacks.add(call.getArgument(1)); return null; });
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
when(server.addRecipe(any(ShapedRecipe.class))).thenAnswer(call -> { recipe = call.getArgument(0); return true; });
|
||||
doAnswer(call -> {
|
||||
var event = call.getArgument(0, Event.class);
|
||||
beforeEvent.accept(event);
|
||||
if (event instanceof PlayerTeleportEvent teleport) {
|
||||
teleports.add(teleport);
|
||||
teleport.setCancelled(cancelTeleport);
|
||||
}
|
||||
for (var listener : event.getHandlers().getRegisteredListeners()) listener.callEvent(event);
|
||||
return null;
|
||||
}).when(events).callEvent(any(Event.class));
|
||||
states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get(5, TimeUnit.SECONDS);
|
||||
states.update(state -> ProgressAdministration.progress(state, owner, Track.DAGGER, 313, false)).get(5, TimeUnit.SECONDS);
|
||||
crafting = new DaggerCrafting(plugin, states, items, new EchoGameplay(plugin, states, new EchoItems()));
|
||||
register(crafting);
|
||||
register(new DaggerUse(states::snapshot, items, new DaggerCooldown(clock::get), DaggerSafety::safe));
|
||||
crafting.run();
|
||||
states.update(state -> state).get(5, TimeUnit.SECONDS);
|
||||
var callback = callbacks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback, "durable crafting receipt must finish before preview");
|
||||
callback.run();
|
||||
}
|
||||
|
||||
ItemStack craft() {
|
||||
var ingredients = new HashMap<Character, net.minecraft.world.item.crafting.Ingredient>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) -> ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)));
|
||||
var nativeRecipe = 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()),
|
||||
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape()), CraftItemStack.asTemplate(recipe.getResult()));
|
||||
var matrix = List.of(Material.HEART_OF_THE_SEA, Material.HEAVY_CORE, Material.HEART_OF_THE_SEA,
|
||||
Material.NETHER_STAR, Material.OMINOUS_TRIAL_KEY, Material.NETHER_STAR,
|
||||
Material.HEART_OF_THE_SEA, Material.DRAGON_EGG, Material.HEART_OF_THE_SEA);
|
||||
for (int i = 0; i < matrix.size(); i++) menu.craftSlots.setItem(i, CraftItemStack.asNMSCopy(new ItemStack(matrix.get(i))));
|
||||
var input = net.minecraft.world.item.crafting.CraftingInput.of(3, 3, menu.craftSlots.getItems());
|
||||
assertTrue(nativeRecipe.matches(input, level));
|
||||
var key = net.minecraft.resources.ResourceKey.<net.minecraft.world.item.crafting.Recipe<?>>create(
|
||||
net.minecraft.core.registries.Registries.RECIPE, net.minecraft.resources.Identifier.fromNamespaceAndPath("spigotignis", "dagger"));
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<net.minecraft.world.item.crafting.CraftingRecipe>(key, nativeRecipe);
|
||||
var recipes = NativeRuntime.recipes();
|
||||
recipes.recipes = net.minecraft.world.item.crafting.RecipeMap.createEmpty();
|
||||
recipes.recipes.addRecipe(holder);
|
||||
recipes.finalizeRecipeLoading(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
|
||||
when(level.recipeAccess()).thenReturn(recipes);
|
||||
menu.craftSlots.setCurrentRecipe(holder);
|
||||
menu.resultSlots.setRecipeUsed(holder);
|
||||
var prepared = CraftEventFactory.callPreCraftEvent(menu.craftSlots, menu.resultSlots, nativeRecipe.assemble(input), menu.getBukkitView(), false);
|
||||
menu.resultSlots.setItem(0, prepared);
|
||||
var craft = new CraftItemEvent(recipe, menu.getBukkitView(), InventoryType.SlotType.RESULT, 0,
|
||||
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
events.callEvent(craft);
|
||||
assertFalse(craft.isCancelled());
|
||||
var result = menu.getResultSlot().remove(1);
|
||||
menu.getResultSlot().onTake(nativePlayer, result);
|
||||
assertTrue(menu.craftSlots.isEmpty(), "native result completion consumes the nine paid ingredients");
|
||||
inventory.setItem(0, CraftItemStack.asBukkitCopy(result));
|
||||
var dagger = inventory.getItem(0);
|
||||
assertTrue(items.identify(dagger).isPresent(), "crafted result must retain its actual durable identity");
|
||||
assertTrue(new TradableRewards(Track.DAGGER).valid(states.snapshot(), owner, items.identify(dagger).orElseThrow()));
|
||||
return dagger;
|
||||
}
|
||||
|
||||
PlayerInteractEvent use() {
|
||||
return CraftEventFactory.callPlayerInteractEvent(nativePlayer, Action.RIGHT_CLICK_AIR,
|
||||
nativeInventory.getItem(0), InteractionHand.MAIN_HAND);
|
||||
}
|
||||
|
||||
void usePacket() {
|
||||
usePacket(InteractionHand.MAIN_HAND);
|
||||
}
|
||||
|
||||
void usePacket(InteractionHand hand) {
|
||||
var packet = new net.minecraft.network.protocol.game.ServerboundUseItemPacket(hand, packets.size() + 1,
|
||||
nativePlayer.getYRot(), nativePlayer.getXRot());
|
||||
packet.handle(connection);
|
||||
}
|
||||
|
||||
CraftPlayer target(double x, double y, double z, float yaw) {
|
||||
var target = mock(CraftPlayer.class);
|
||||
when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(target.isOnline()).thenReturn(true);
|
||||
when(target.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
when(target.getLocation()).thenAnswer(call -> new Location(world, x, y, z, yaw, 0));
|
||||
when(target.getEyeLocation()).thenAnswer(call -> new Location(world, x, y + 1.62, z, yaw, 0));
|
||||
players.add(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
void position(double x, double y, double z, float yaw, float pitch) throws Exception {
|
||||
seedPosition(nativePlayer, new Vec3(x, y, z));
|
||||
nativePlayer.setYRot(yaw);
|
||||
nativePlayer.setYHeadRot(yaw);
|
||||
nativePlayer.setXRot(pitch);
|
||||
}
|
||||
|
||||
private void register(Listener listener) throws Exception {
|
||||
for (var method : listener.getClass().getMethods()) {
|
||||
var annotation = method.getAnnotation(EventHandler.class);
|
||||
if (annotation == null) continue;
|
||||
var eventType = method.getParameterTypes()[0].asSubclass(Event.class);
|
||||
var handlers = (HandlerList) eventType.getMethod("getHandlerList").invoke(null);
|
||||
handlers.register(new RegisteredListener(listener, EventExecutor.create(method, eventType), annotation.priority(), plugin, annotation.ignoreCancelled()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void seedPosition(ServerPlayer player, Vec3 position) throws Exception {
|
||||
set(Entity.class, player, "position", position);
|
||||
set(Entity.class, player, "dimensions", EntityTypes.PLAYER.getDimensions());
|
||||
set(Entity.class, player, "bb", EntityTypes.PLAYER.getDimensions().makeBoundingBox(position));
|
||||
set(Entity.class, player, "posLock", new Object());
|
||||
set(Entity.class, player, "blockPosition", BlockPos.containing(position));
|
||||
set(Entity.class, player, "chunkPosition", net.minecraft.world.level.ChunkPos.containing(BlockPos.containing(position)));
|
||||
set(Entity.class, player, "levelCallback", net.minecraft.world.level.entity.EntityInLevelCallback.NULL);
|
||||
set(Entity.class, player, "firstTick", true);
|
||||
}
|
||||
|
||||
static void set(Class<?> type, Object target, String name, Object value) throws Exception {
|
||||
var field = type.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
HandlerList.unregisterAll(plugin);
|
||||
crafting.close();
|
||||
states.close();
|
||||
settings.close();
|
||||
platform.close();
|
||||
}
|
||||
|
||||
@FunctionalInterface interface TickWork { void run() throws Exception; }
|
||||
static void onTick(TickWork work) throws Exception {
|
||||
var done = new CompletableFuture<Void>();
|
||||
var thread = new TickThread(() -> {
|
||||
try { work.run(); done.complete(null); }
|
||||
catch (Throwable failure) { done.completeExceptionally(failure); }
|
||||
}, "Ignis native Dagger acceptance");
|
||||
thread.start();
|
||||
try { done.get(30, TimeUnit.SECONDS); }
|
||||
catch (java.util.concurrent.ExecutionException failure) {
|
||||
if (failure.getCause() instanceof AssertionError assertion) throw assertion;
|
||||
if (failure.getCause() instanceof Exception exception) throw exception;
|
||||
throw failure;
|
||||
} finally { thread.join(5000); }
|
||||
assertFalse(thread.isAlive());
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,9 @@ class NativeEchoAnnouncementTest {
|
||||
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "isPrimaryThread" -> true;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
|
||||
case "getMinecraftVersion" -> "26.3";
|
||||
case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
})) {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
|
||||
@@ -21,6 +21,7 @@ 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;
|
||||
@@ -29,6 +30,7 @@ import org.bukkit.craftbukkit.CraftRegistry;
|
||||
final class NativeRuntime {
|
||||
private NativeRuntime() { }
|
||||
private static boolean ready;
|
||||
private static RecipeManager recipes;
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (ready) { return; }
|
||||
SharedConstants.tryDetectVersion();
|
||||
@@ -40,12 +42,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());
|
||||
@@ -54,7 +56,16 @@ final class NativeRuntime {
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
recipes = datapack.getRecipeManager();
|
||||
recipes.finalizeRecipeLoading(flags);
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
|
||||
static RecipeManager recipes() {
|
||||
if (!ready) {
|
||||
throw new IllegalStateException("Native runtime has not been bootstrapped");
|
||||
}
|
||||
return recipes;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerSafetyTest {
|
||||
@Test
|
||||
void requiresClearLoadedSupportedInBorderDestinationWithoutMutatingPlayerBounds() {
|
||||
void requiresClearLoadedInBorderDestinationWithoutGroundOrMutatingPlayerBounds() {
|
||||
var world = mock(World.class);
|
||||
var border = mock(WorldBorder.class);
|
||||
when(world.getWorldBorder()).thenReturn(border);
|
||||
@@ -33,7 +33,6 @@ class DaggerSafetyTest {
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(player.getBoundingBox()).thenReturn(body);
|
||||
when(player.wouldCollideUsing(any())).thenReturn(true); // Supporting floor beneath candidate.
|
||||
var destination = new Location(world, 0, 64, 3);
|
||||
assertTrue(DaggerSafety.safe(player, destination));
|
||||
var protruding = mock(Block.class);
|
||||
@@ -45,9 +44,7 @@ class DaggerSafetyTest {
|
||||
assertFalse(DaggerSafety.safe(player, destination), "Neighbor collision shapes can protrude outside their block cell");
|
||||
when(world.getBlockAt(1, 64, 0)).thenReturn(air);
|
||||
assertEquals(new BoundingBox(-.3, 64, -.3, .3, 65.8, .3), body);
|
||||
when(player.wouldCollideUsing(any())).thenReturn(false);
|
||||
assertFalse(DaggerSafety.safe(player, destination));
|
||||
when(player.wouldCollideUsing(any())).thenReturn(true);
|
||||
assertTrue(DaggerSafety.safe(player, destination), "unsupported air is a valid exit");
|
||||
when(air.getType()).thenReturn(Material.LAVA);
|
||||
assertFalse(DaggerSafety.safe(player, destination));
|
||||
when(air.getType()).thenReturn(Material.AIR);
|
||||
|
||||
@@ -21,6 +21,9 @@ class PurpurDistributionTest {
|
||||
assertEquals(version, metadata.get("version"));
|
||||
assertEquals("SpigotIgnis", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals("games.dmg.spigotignis.SpigotIgnisPlugin", metadata.get("main"));
|
||||
assertEquals("1.21", metadata.get("api-version"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("commands")).keySet().containsAll(
|
||||
java.util.Set.of("ignis", "ignisadmin")));
|
||||
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("ignis.admin"));
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
|
||||
var descriptor = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
@@ -42,10 +45,18 @@ class PurpurDistributionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
|
||||
void releaseAndCiAgreeWithThePurpur26_3DistributionAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
|
||||
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"));
|
||||
assertTrue(build.contains("net.dmulloy2:ProtocolLib:5.4.0"));
|
||||
assertFalse(build.contains("26.2") || build.contains("2618"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
String readme = Files.readString(Path.of("README.md"));
|
||||
assertTrue(readme.contains("Purpur `26.3.build.2651-stable`"));
|
||||
assertFalse(readme.contains("26.2") || readme.contains("2618"));
|
||||
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
|
||||
String content = Files.readString(Path.of(".gitea/workflows", workflow));
|
||||
assertTrue(content.contains("java-version: 25"));
|
||||
|
||||
Reference in new issue
Block a user