3 Commits
Author SHA1 Message Date
dmg 6a4d0c530b fix(ignis): allow safe Dagger blinks in midair (#1)
Release / release (push) Successful in 2m13s
CI / build (push) Successful in 51s
US-011: 201 local tests pass; exact-head CI 3268 and 3269 pass. No deployment approval.
2026-10-12 19:22:25 +00:00
dmg 7e60224096 fix(ignis): allow safe Dagger blinks in midair
CI / build (pull_request) Successful in 1m22s
CI / build (push) Successful in 1m15s
Retain swept collision, hazards, loaded chunks and protection checks
without requiring a supported exit.

Add native paid crafting, item-use packet, target movement and safeguard
regression coverage; 201 tests pass.
2026-10-12 15:11:47 -04:00
dmg 3f721e928a fix(ignis): retain Echo recovery warnings during shutdown
Release / release (push) Successful in 4m57s
CI / build (push) Successful in 1m13s
Report failed reward saves independently of the tick callback with owner and receipt identifiers. Verify the approved lightweight consumption, inert-copy and failure/retry contract using native inventories and real persistence. Keep cross-save hard-crash recovery an explicit operator limitation; add no autosave-length restrictions or transaction protocol.
2026-09-12 23:15:21 -04:00
9 changed files with 1011 additions and 18 deletions

No files matched your search

+8 -4
View File
@@ -37,7 +37,7 @@ Take uncancelled void damage, ascend while gliding with elytra in the same world
The earned Echo Shard is named **Echo of the Abyss**, with cryptic lore and a UUID-bound receipt. Right-click it to activate permanent obfuscated-name advancement announcements. It does not hide general identity or alter advancement titles/descriptions. Only the bound owner may use it, and activation cannot grant twice. Lost, unconsumed rewards can be earned again by repeating the feat. Admins can also grant a bound Echo.
Full inventories retain a pending reward; online delivery retries every five seconds using the same receipt identity. Temporary inventory/drop/swap locks prevent ordinary moves during the short activation commit. Disk failures do not consume the item on activation; failed completion saves produce a player-facing recovery message rather than pretending the reward was saved.
Full inventories retain a pending reward; online delivery retries every five seconds using the same receipt identity. Temporary inventory/drop/swap locks prevent ordinary moves during the short activation commit. Disk failures do not consume the item on activation, so it can be retried. Failed challenge-completion saves log the owner UUID and receipt ID for administrator recovery even if shutdown prevents the player callback; an online player is also notified when scheduling remains available. Pending delivery acknowledgements can retry without offering another copy while the original remains in the inventory.
Announcement handling uses Purpur's Adventure `PlayerAdvancementDoneEvent.message`, replacing only the first name argument of the three vanilla advancement translations. It preserves silent events and unrelated/custom message formats. An advancement-only ProtocolLib output backstop also covers Purpur's affected-player-only branch, which sends the original pre-event component rather than the event's replacement. The backstop identifies an unambiguous player UUID in the name slot's entity hover and checks saved activation; it does not infer identity from name text, touch titles/descriptions or broaden recipients. Ordinary/custom chat remains unchanged. Native activation/reopen, event/delivery branches, packet codecs and plugin lifecycle tests cover this fix; the exact live report setting/format remains unconfirmed. Existing Stealth outbound masking sees the obfuscated alias rather than the original name. Live coexistence remains unverified.
@@ -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
@@ -116,12 +116,16 @@ Offline targets use UUIDs already present in Ignis state; online exact names are
Writes use a same-directory temporary file, flush it to disk, and require atomic replacement. Unsupported atomic moves or write failures retain the prior active snapshot and fail the mutation; there is no non-atomic fallback. Abandoned temporary files are ignored, never auto-promoted. This does not guarantee survival of arbitrary storage hardware or power failures.
For a reported failed Echo award, restore writable storage first and match the logged owner UUID/receipt to the player. Inspect saved activation and pending rewards before granting anything: pending rewards retry automatically when inventory space is available. If the failed award needs replacement, use the existing operator-only `/ignisadmin give <target> echo` after review. A UUID not yet saved in Ignis must reconnect so its exact online name can be targeted. Do not reset earned progression merely to remove an inert leftover item, and do not grant a second reward for an already activated benefit.
If loading fails, preserve the original file for investigation. Under an approved maintenance plan, stop the server, back up current data, restore a verified matching-version backup, and restart. Do not delete state to recover: that loses earned progress and invalidation records. Do not hand-edit the binary file or import another plugin's state. Restoring an old backup can also restore older revocation/progress state; review that consequence before doing it.
## Verification limits
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.
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. These limitations prevent claiming complete live/crash acceptance or deploying automatically.
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.
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.
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, hazard refusals and allowed unsupported exits, world/chunk boundaries, cooldowns across copies/disconnect, and cancelled or redirected protection-plugin teleports.
@@ -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) {
@@ -60,13 +60,19 @@ public final class EchoGameplay implements Listener {
UUID owner = player.getUniqueId(), receipt = UUID.randomUUID();
long generation = states.snapshot().players().getOrDefault(owner, PlayerState.empty()).track(Track.VOID).generation();
states.update(state -> state.players().getOrDefault(owner, PlayerState.empty()).track(Track.VOID).generation() == generation
? EchoRewards.award(state, owner, receipt) : state).whenComplete((state, error) -> onServer(() -> {
? EchoRewards.award(state, owner, receipt) : state).whenComplete((state, error) -> {
// Report independently of the tick callback: disable can prevent its delivery.
if (error != null) {
plugin.getLogger().warning("Echo reward save failed; administrator recovery is required.");
Player online = plugin.getServer().getPlayer(owner);
if (online != null) online.sendMessage("Your void reward could not be saved. Please contact an administrator.");
} else retryDeliveries();
}));
plugin.getLogger().warning("Echo reward save failed for " + owner + " (receipt " + receipt
+ "); administrator recovery is required.");
}
onServer(() -> {
if (error != null) {
Player online = plugin.getServer().getPlayer(owner);
if (online != null) online.sendMessage("Your void reward could not be saved. Please contact an administrator.");
} else retryDeliveries();
});
});
}
}
@@ -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,381 @@
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);
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 = new net.minecraft.world.item.crafting.RecipeManager(org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
recipes.recipes = net.minecraft.world.item.crafting.RecipeMap.create(List.of(holder));
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());
}
}
@@ -0,0 +1,229 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityEquipment;
import net.minecraft.world.entity.player.Inventory;
import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.plugin.java.JavaPlugin;
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.CsvSource;
class NativeEchoLifecycleTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@ParameterizedTest
@CsvSource({"0,1", "0,3", "40,1", "40,3"})
void actualInventoryConsumesExactlyOneOnceAndReopensActivated(int slot, int amount) throws Exception {
try (var f = new Fixture(directory)) {
var echo = f.items.create(f.token);
echo.setAmount(amount);
f.inventory.setItem(slot, echo);
var use = f.use(slot);
f.gameplay.onUse(use);
f.gameplay.onUse(use);
f.callback();
assertTrue(f.gameplay.activated(f.owner));
assertEquals(amount - 1, f.count());
f.gameplay.onUse(use);
assertEquals(amount - 1, f.count());
assertFalse(EchoRewards.valid(f.states.snapshot(), f.owner, f.token));
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
assertTrue(new EchoGameplay(f.plugin, reopened, f.items).activated(f.owner));
assertFalse(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
}
}
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"disconnect", "death", "disable"})
void interruptedCopiesRemainInertAfterReopen(String interruption) throws Exception {
try (var f = new Fixture(directory)) {
var copy = f.items.create(f.token);
f.inventory.setItem(0, copy);
f.gameplay.onUse(f.use(0));
var callback = f.awaitCallback();
assertTrue(f.gameplay.activated(f.owner));
if (interruption.equals("disconnect")) {
var quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(f.player);
f.gameplay.onQuit(quit);
when(f.server.getPlayer(f.owner)).thenReturn(null);
callback.run();
assertEquals(1, f.count());
} else if (interruption.equals("death")) {
// The world/drop boundary removes the item before the pending callback.
f.inventory.clear();
var death = mock(org.bukkit.event.entity.PlayerDeathEvent.class);
when(death.getEntity()).thenReturn(f.player);
f.gameplay.onDeath(death);
callback.run();
f.inventory.setItem(0, copy); // later pickup of the retained drop
} else {
f.enabled.set(false); // Bukkit cancels the queued callback on disable; do not execute it.
assertEquals(1, f.count());
}
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
var resumed = new EchoGameplay(f.plugin, reopened, f.items);
f.enabled.set(true);
when(f.server.getPlayer(f.owner)).thenReturn(f.player);
assertTrue(resumed.activated(f.owner));
resumed.onUse(f.use(0));
assertEquals(1, f.count(), "interrupted copies are inert, not consumed a second time");
assertFalse(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
assertTrue(f.callbacks.isEmpty(), "a spent receipt cannot schedule another activation");
}
}
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"foreign", "unknown", "reset", "denied"})
void onlyTheCurrentOwnerReceiptCanActivateAndProtectionIsRespected(String rejection) throws Exception {
try (var f = new Fixture(directory)) {
var token = rejection.equals("unknown") ? new EchoToken(f.owner, UUID.randomUUID(), 0) : f.token;
var echo = f.items.create(token);
f.inventory.setItem(0, echo);
if (rejection.equals("foreign")) { when(f.player.getUniqueId()).thenReturn(UUID.randomUUID()); }
if (rejection.equals("reset")) {
f.states.update(state -> ProgressAdministration.reset(state, f.owner, java.util.Set.of(Track.VOID))).get(5, TimeUnit.SECONDS);
f.states.update(state -> EchoRewards.award(state, f.owner, UUID.randomUUID())).get(5, TimeUnit.SECONDS);
}
var use = f.use(0);
if (rejection.equals("denied")) { use.setUseItemInHand(org.bukkit.event.Event.Result.DENY); }
f.gameplay.onUse(use);
assertFalse(f.gameplay.activated(f.owner));
assertEquals(echo, f.inventory.getItem(0));
assertTrue(f.callbacks.isEmpty());
}
}
@org.junit.jupiter.api.Test
void failedActivationLeavesRealItemsUnconsumedAndCanBeRetried() throws Exception {
try (var f = new Fixture(directory)) {
var echo = f.items.create(f.token);
echo.setAmount(3);
f.inventory.setItem(0, echo);
f.failSave.set(true);
f.gameplay.onUse(f.use(0));
f.callback();
assertFalse(f.gameplay.activated(f.owner));
assertEquals(3, f.count());
verify(f.player).sendMessage(contains("It has not been consumed; try again"));
f.failSave.set(false);
f.gameplay.onUse(f.use(0));
f.callback();
assertTrue(f.gameplay.activated(f.owner));
assertEquals(2, f.count());
}
}
@org.junit.jupiter.api.Test
void actualFullInventoryDefersAndFailedAcknowledgementRetriesWithoutAnotherOffer() throws Exception {
try (var f = new Fixture(directory)) {
for (int slot = 0; slot < 36; slot++) {
f.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.COBBLESTONE, 64));
}
f.gameplay.retryDeliveries();
assertEquals(0, f.count());
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().containsKey(f.token.receipt()));
assertTrue(f.callbacks.isEmpty());
f.inventory.setItem(5, null);
f.failSave.set(true);
f.gameplay.retryDeliveries();
f.gameplay.retryDeliveries();
f.callback();
assertEquals(1, f.count());
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().containsKey(f.token.receipt()));
f.failSave.set(false);
f.gameplay.retryDeliveries();
f.callback();
f.gameplay.retryDeliveries();
assertEquals(1, f.count());
assertTrue(f.states.snapshot().players().get(f.owner).pendingRewards().isEmpty());
try (var reopened = StateService.open(f.disk).get(5, TimeUnit.SECONDS)) {
assertTrue(reopened.snapshot().players().get(f.owner).pendingRewards().isEmpty());
assertTrue(EchoRewards.valid(reopened.snapshot(), f.owner, f.token));
}
}
}
private static final class Fixture implements AutoCloseable {
final UUID owner = UUID.randomUUID();
final EchoToken token = new EchoToken(owner, UUID.randomUUID(), 0);
final EchoItems items = new EchoItems();
final CraftServer server = mock(CraftServer.class);
final org.mockito.MockedStatic<Bukkit> platform;
final JavaPlugin plugin = mock(JavaPlugin.class);
final CraftPlayer player = mock(CraftPlayer.class);
final CraftInventoryPlayer inventory;
final LinkedBlockingQueue<Runnable> callbacks = new LinkedBlockingQueue<>();
final AtomicBoolean enabled = new AtomicBoolean(true), failSave = new AtomicBoolean();
final FileStateRepository disk;
final StateService states;
final EchoGameplay gameplay;
Fixture(Path directory) throws Exception {
platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
default -> call.callRealMethod();
});
var nativePlayer = mock(ServerPlayer.class);
when(nativePlayer.getBukkitEntity()).thenReturn(player);
when(player.getHandle()).thenReturn(nativePlayer);
inventory = new CraftInventoryPlayer(new Inventory(nativePlayer, new EntityEquipment()));
when(player.getInventory()).thenReturn(inventory);
when(player.getUniqueId()).thenReturn(owner);
when(plugin.getServer()).thenReturn(server);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
when(plugin.isEnabled()).thenAnswer(call -> enabled.get());
when(server.getPlayer(owner)).thenReturn(player);
doReturn(List.of(player)).when(server).getOnlinePlayers();
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; });
disk = new FileStateRepository(directory.resolve("state.bin"));
states = StateService.open(new StateRepository() {
@Override public IgnisState load() throws IOException { return disk.load(); }
@Override public void save(IgnisState state) throws IOException {
if (failSave.get()) { throw new IOException("simulated unavailable storage"); }
disk.save(state);
}
}).get(5, TimeUnit.SECONDS);
states.update(state -> EchoRewards.award(state, owner, token.receipt())).get(5, TimeUnit.SECONDS);
gameplay = new EchoGameplay(plugin, states, items);
}
PlayerInteractEvent use(int slot) {
return new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, inventory.getItem(slot), null,
org.bukkit.block.BlockFace.SELF, slot == 40 ? EquipmentSlot.OFF_HAND : EquipmentSlot.HAND);
}
Runnable awaitCallback() throws InterruptedException {
var callback = callbacks.poll(5, TimeUnit.SECONDS);
assertNotNull(callback);
return callback;
}
void callback() throws InterruptedException { awaitCallback().run(); }
int count() {
return java.util.Arrays.stream(inventory.getContents()).filter(item -> items.identify(item).filter(token::equals).isPresent())
.mapToInt(org.bukkit.inventory.ItemStack::getAmount).sum();
}
@Override public void close() { states.close(); platform.close(); }
}
}
@@ -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);
@@ -0,0 +1,92 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.plugin.java.JavaPlugin;
class EchoRewardFailureTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"online", "offline", "disabled", "scheduler-rejected"})
void failedEarnedRewardReportsRecoveryEvenWithoutPlayerCallback(String mode) throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
var repository = new StateRepository() {
@Override public IgnisState load() { return IgnisState.empty(); }
@Override public void save(IgnisState state) throws IOException {
entered.countDown();
try { if (!release.await(5, TimeUnit.SECONDS)) { throw new IOException("test barrier timeout"); } }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IOException(failure); }
throw new IOException("simulated unavailable storage");
}
};
var enabled = new AtomicBoolean(true);
var plugin = mock(JavaPlugin.class);
when(plugin.isEnabled()).thenAnswer(call -> enabled.get());
var server = mock(Server.class);
when(plugin.getServer()).thenReturn(server);
var logger = mock(java.util.logging.Logger.class);
when(plugin.getLogger()).thenReturn(logger);
var player = mock(Player.class);
UUID owner = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(owner);
if (mode.equals("online")) { when(server.getPlayer(owner)).thenReturn(player); }
var callbacks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
if (mode.equals("scheduler-rejected")) { throw new org.bukkit.plugin.IllegalPluginAccessException("simulated shutdown"); }
callbacks.add(call.getArgument(1));
return null;
});
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(player.getWorld()).thenReturn(world);
var block = mock(Block.class);
when(block.getType()).thenReturn(Material.END_STONE);
when(world.getBlockAt(any(Location.class))).thenReturn(block);
try (var states = StateService.open(repository).get(5, TimeUnit.SECONDS)) {
var gameplay = new EchoGameplay(plugin, states, new EchoItems());
var damage = mock(EntityDamageEvent.class);
when(damage.getEntity()).thenReturn(player);
when(damage.getCause()).thenReturn(EntityDamageEvent.DamageCause.VOID);
when(damage.getFinalDamage()).thenReturn(4.0);
gameplay.onDamage(damage);
var move = mock(PlayerMoveEvent.class);
when(move.getPlayer()).thenReturn(player);
when(move.hasChangedPosition()).thenReturn(true);
when(move.getTo()).thenReturn(new Location(world, 0, 10, 0));
when(player.isGliding()).thenReturn(true);
gameplay.onMove(move);
when(player.isOnGround()).thenReturn(true);
gameplay.onMove(move);
assertTrue(entered.await(5, TimeUnit.SECONDS));
enabled.set(!mode.equals("disabled"));
release.countDown();
states.update(state -> state).handle((state, failure) -> null).get(5, TimeUnit.SECONDS);
verify(logger).warning(contains("administrator recovery"));
verify(logger).warning(contains(owner.toString()));
assertFalse(states.snapshot().players().containsKey(owner));
verify(server, never()).getPlayer(any(UUID.class));
if (mode.equals("online") || mode.equals("offline")) {
var callback = callbacks.poll(5, TimeUnit.SECONDS);
assertNotNull(callback);
callback.run();
} else { assertTrue(callbacks.isEmpty()); }
if (mode.equals("online")) { verify(player).sendMessage(contains("contact an administrator")); }
else { verify(player, never()).sendMessage(anyString()); }
} finally { release.countDown(); }
}
}