Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b61d7b43 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Spigot Ignis
|
||||
|
||||
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss, advancement-name scrambling, and The Mask are implemented in source; live-server/client verification is pending.** Dagger and Heart gameplay are not implemented yet.
|
||||
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss, advancement-name scrambling, The Mask, and The Dagger are implemented in source; live-server/client verification is pending.** Heart gameplay is not implemented yet.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -24,7 +24,7 @@ CI/release workflows publish versioned source artifacts; verify their exact run/
|
||||
- `StateService.open(repository)` loads on a dedicated persistence worker and completes only when ready. Failed/corrupt loads do not substitute empty state. No caller may use gameplay state before successful initialization.
|
||||
- `snapshot()` provides an immutable, last-successfully-saved view. `update(pureChange)` serializes mutations, saves first, then publishes/acknowledges. Mutation functions run off-thread and must not access Bukkit or perform gameplay side effects. Marshal downstream gameplay work back to the server thread.
|
||||
- `configure(invisibilityMillis, pearls, darknessMillis)` validates positive thresholds and atomically saves limits with resulting unlocks. Defaults are 43,200,000 ms (12 h), 313 pearls, and 21,600,000 ms (6 h). Lower limits unlock satisfied tracks during evaluation; higher limits do not revoke unlocks. VOID is objective-based and is excluded from numeric threshold grants.
|
||||
- UUID state includes per-track progress, unlock/activation flags, invalidation generations, pending rewards, and issued-item receipts. Echo validity requires a matching owner, receipt, and generation; reset removes relevant receipts and advances the generation. Future tradable-item provenance remains a later design decision.
|
||||
- UUID state includes per-track progress, unlock/activation flags, invalidation generations, pending rewards, and issued-item receipts. Echo validity requires a matching owner, receipt, and generation; reset removes relevant receipts and advances the generation. Mask/Dagger validity additionally requires the holder's personal unlock and the issuer's matching track receipt/generation.
|
||||
- `close()` rejects new mutations and lets already queued writes drain without blocking the caller. Hard process termination can lose unacknowledged updates. The worker does not force Bukkit/world access from its thread.
|
||||
|
||||
Bootstrap reads only `plugins/SpigotIgnis/state.bin`; it does not read or migrate Stealth data. Listeners, commands, and pending-delivery retries register only after successful asynchronous initialization. Runtime limits have a Java service API and admin commands; there is no hand-edited configuration file.
|
||||
@@ -53,9 +53,31 @@ A crafter's generation can share a recipe receipt across multiple legitimately p
|
||||
|
||||
Wearing a valid Mask applies managed invisibility. Effect ownership is established only when this plugin's application actually takes effect; external replacements revoke ownership, so unequipping/resetting removes only Ignis's contribution. ProtocolLib projects only the helmet slot to air for eligible invisible wearers and restores equipment presentation when eligibility changes. Other armor and server inventory contents are not mutated by projection. Actual self/other-player rendering and inventory-screen behavior remain manual acceptance checks.
|
||||
|
||||
## The Dagger
|
||||
|
||||
The default unlock is **313 consumed ender-pearl throws**, configurable through `limits set pearls`. A launch event alone does not count: the adapter correlates the pending pearl launch with its decreased item-stack mirror and the subsequent vanilla `USE_ITEM` notification. Cancelled launches, unsuccessful spawns without that notification, unconsumed/free throws, unrelated statistics, and duplicate notifications earn nothing. Cancelling the statistic update itself does not undo a pearl already consumed. This does not read or import players' vanilla statistic totals. Exact Purpur event ordering and servers/plugins suppressing those notifications remain live-verification requirements.
|
||||
|
||||
Counts save off-thread, with in-memory retry of failed credit. Checkpoints wait for reserved writes rather than acknowledging early; reset generations discard stale buffers. Administrative counter edits drain old credits before applying the requested value. Shutdown drains the pearl tracker before closing the persistence worker. Unacknowledged credit cannot be guaranteed through a hard crash or unavailable storage.
|
||||
|
||||
The authenticated Ominous Key is named **The Dagger**. Its unchanged recipe is:
|
||||
|
||||
| | | |
|
||||
| --- | --- | --- |
|
||||
| Heart of the Sea | Heavy Core | Heart of the Sea |
|
||||
| Nether Star | Ominous Key | Nether Star |
|
||||
| Heart of the Sea | Dragon Egg | Heart of the Sea |
|
||||
|
||||
Dagger crafting uses the same durable issuance policy as the Mask: vanilla retains ingredient consumption, locked/reset outputs and automated Crafters are denied, and pending admin gifts are not used as paid-craft receipts. `TradableRewards` shares the ledger rules without changing existing Mask PDC identities or schema version. Both issuer and holder need their own unlock; issuer reset invalidates traded copies.
|
||||
|
||||
Right-click normally attempts **three blocks along the aim direction**. A visible player within **eight blocks** and a **three-dimensional 15-degree half-angle** cone instead selects a destination **1.5 horizontal blocks behind their facing direction**. Closest-to-crosshair selection uses UUID order for ties. Invisible/vanished, dead, and spectator targets are excluded. The user's look orientation is retained. Line-of-sight queries first check that their chunk corridor is loaded.
|
||||
|
||||
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.
|
||||
|
||||
## Commands
|
||||
|
||||
All players may use `/ignis umbra` to privately see the three item tracks. The Mask counter is active; Dagger/Heart natural counters and items remain unimplemented, though administrator-seeded values can be inspected. No passive progress UI is added.
|
||||
All players may use `/ignis umbra` to privately see the three item tracks. Mask and Dagger counters are active; Heart natural progression and its item remain unimplemented, though administrator-seeded values can be inspected. No passive progress UI is added.
|
||||
|
||||
Administration requires **`ignis.admin`** (default: operators), checked independently during execution and autocomplete:
|
||||
|
||||
@@ -65,12 +87,12 @@ Administration requires **`ignis.admin`** (default: operators), checked independ
|
||||
/ignisadmin limits set <mask-ms|pearls|darkness-ms> <positive-value>
|
||||
/ignisadmin progress <set|add> <online-name|known-UUID> <mask|dagger|heart> <nonnegative-value>
|
||||
/ignisadmin complete <online-name|known-UUID> void
|
||||
/ignisadmin give <online-name|known-UUID> <echo|mask>
|
||||
/ignisadmin give <online-name|known-UUID> <echo|mask|dagger>
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all>
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all> confirm
|
||||
```
|
||||
|
||||
Reset confirmation must match the same sender, target, and tracks within 30 seconds; `confirm` alone cannot bypass the initial request. Reset clears the selected progress/unlocks/activation, pending rewards and issuance receipts, and advances generation, making old Echo copies invalid even after re-earning. Other tracks are unchanged by a single-track reset. Echo grants unlock eligibility but still require consumption to activate scrambling; already activated players do not gain another activation. Admin Mask grants unlock use and queue the authenticated item for delivery after profile resolution/inventory availability. Admin mutations are audited, not routine private progress reads.
|
||||
Reset confirmation must match the same sender, target, and tracks within 30 seconds; `confirm` alone cannot bypass the initial request. Reset clears the selected progress/unlocks/activation, pending rewards and issuance receipts, and advances generation, making old Echo copies invalid even after re-earning. Other tracks are unchanged by a single-track reset. Echo grants unlock eligibility but still require consumption to activate scrambling; already activated players do not gain another activation. Admin Mask/Dagger grants unlock use and queue authenticated items for inventory-ready delivery; new Masks also require profile resolution. Mask/Dagger delivery retries remember an already offered gift while its acknowledgement save is failing, so trading it away does not cause another offer in the same plugin session. This marker is not durable across process restart; failed-acknowledgement/restart recovery still requires operator review. Admin mutations are audited, not routine private progress reads.
|
||||
|
||||
Offline targets use UUIDs already present in Ignis state; online exact names are resolved without network profile lookups. Commands and contextual completion do not claim that unimplemented reward items are available.
|
||||
|
||||
@@ -88,4 +110,4 @@ JUnit tests exercise real temporary-file persistence, serialized writes, fault i
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -0,0 +1,18 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Server-thread, per-player activation gate, independent of item copies and teleport outcome. */
|
||||
public final class DaggerCooldown {
|
||||
private final LongSupplier clock;
|
||||
private final java.util.Map<UUID, Long> started = new java.util.HashMap<>();
|
||||
public DaggerCooldown(LongSupplier clock) { this.clock = clock; }
|
||||
public boolean attempt(UUID player) {
|
||||
long now = clock.getAsLong();
|
||||
started.entrySet().removeIf(entry -> now - entry.getValue() >= 2_000_000_000L);
|
||||
if (started.containsKey(player)) return false;
|
||||
started.put(player, now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class DaggerCrafting implements Listener, Runnable {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("spigotignis", "dagger");
|
||||
private static final TradableRewards REWARDS = new TradableRewards(Track.DAGGER);
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final DaggerItems items;
|
||||
private final EchoGameplay dispatch;
|
||||
private final java.util.function.Function<org.bukkit.inventory.ItemStack, org.bukkit.inventory.ShapedRecipe> recipeFactory;
|
||||
public DaggerCrafting(JavaPlugin plugin, StateService states, DaggerItems items, EchoGameplay dispatch) {
|
||||
this(plugin, states, items, dispatch, result -> new org.bukkit.inventory.ShapedRecipe(RECIPE, result));
|
||||
}
|
||||
DaggerCrafting(JavaPlugin plugin, StateService states, DaggerItems items, EchoGameplay dispatch,
|
||||
java.util.function.Function<org.bukkit.inventory.ItemStack, org.bukkit.inventory.ShapedRecipe> recipeFactory) {
|
||||
this.plugin = plugin; this.states = states; this.items = items; this.dispatch = dispatch;
|
||||
this.recipeFactory = recipeFactory;
|
||||
}
|
||||
private boolean registered;
|
||||
private final java.util.Set<java.util.UUID> preparing = new java.util.HashSet<>();
|
||||
private final java.util.Set<ItemToken> delivering = new java.util.HashSet<>();
|
||||
private final java.util.Set<ItemToken> offered = new java.util.HashSet<>();
|
||||
@Override public void run() {
|
||||
if (!registered) {
|
||||
var recipe = recipeFactory.apply(items.prototype());
|
||||
configureRecipe(recipe);
|
||||
registered = plugin.getServer().addRecipe(recipe);
|
||||
if (!registered) return;
|
||||
}
|
||||
for (var player : plugin.getServer().getOnlinePlayers()) {
|
||||
var state = states.snapshot().players().getOrDefault(player.getUniqueId(), PlayerState.empty());
|
||||
if (!state.track(Track.DAGGER).unlocked()) {
|
||||
if (player.hasDiscoveredRecipe(RECIPE)) player.undiscoverRecipe(RECIPE);
|
||||
continue;
|
||||
}
|
||||
ensureReceipt(player);
|
||||
if (!player.hasDiscoveredRecipe(RECIPE)) player.discoverRecipe(RECIPE);
|
||||
for (var entry : state.pendingRewards().entrySet()) {
|
||||
if (entry.getValue() != Track.DAGGER) continue;
|
||||
var token = new ItemToken(player.getUniqueId(), entry.getKey(), state.track(Track.DAGGER).generation());
|
||||
if (delivering.contains(token)) continue;
|
||||
if (!offered.contains(token) && !items.offer(player, token)) continue;
|
||||
offered.add(token);
|
||||
delivering.add(token);
|
||||
states.update(current -> REWARDS.acknowledge(current, token))
|
||||
.whenComplete((ignored, error) -> dispatch.onServer(() -> {
|
||||
delivering.remove(token);
|
||||
if (error == null) offered.remove(token);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
private void ensureReceipt(org.bukkit.entity.Player player) {
|
||||
var owner = player.getUniqueId();
|
||||
var snapshot = states.snapshot();
|
||||
var state = snapshot.players().getOrDefault(owner, PlayerState.empty()).track(Track.DAGGER);
|
||||
if (!state.unlocked() || REWARDS.craftingToken(snapshot, owner).isPresent() || !preparing.add(owner)) return;
|
||||
var receipt = java.util.UUID.randomUUID();
|
||||
long generation = state.generation();
|
||||
states.update(current -> {
|
||||
var now = current.players().getOrDefault(owner, PlayerState.empty()).track(Track.DAGGER);
|
||||
return now.unlocked() && now.generation() == generation ? REWARDS.issue(current, owner, receipt, false) : current;
|
||||
}).whenComplete((ignored, error) -> dispatch.onServer(() -> {
|
||||
preparing.remove(owner);
|
||||
var online = plugin.getServer().getPlayer(owner);
|
||||
if (error != null || online == null) return;
|
||||
var view = online.getOpenInventory();
|
||||
if (view.getTopInventory() instanceof org.bukkit.inventory.CraftingInventory inventory && matches(inventory.getRecipe())) {
|
||||
inventory.setResult(items.prototype());
|
||||
plugin.getServer().getPluginManager().callEvent(new org.bukkit.event.inventory.PrepareItemCraftEvent(inventory, view, false));
|
||||
online.updateInventory();
|
||||
}
|
||||
}));
|
||||
}
|
||||
public void close() { if (registered) plugin.getServer().removeRecipe(RECIPE); }
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (!matches(event.getRecipe())) return;
|
||||
var identity = items.identify(event.getInventory().getResult());
|
||||
if (!(event.getWhoClicked() instanceof org.bukkit.entity.Player player) || identity.isEmpty()
|
||||
|| !identity.orElseThrow().issuer().equals(player.getUniqueId())
|
||||
|| !REWARDS.valid(states.snapshot(), player.getUniqueId(), identity.orElseThrow())) {
|
||||
event.setCancelled(true);
|
||||
event.getInventory().setResult(null);
|
||||
}
|
||||
}
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCrafter(CrafterCraftEvent event) {
|
||||
if (matches(event.getRecipe())) event.setCancelled(true);
|
||||
}
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void onPrepare(org.bukkit.event.inventory.PrepareItemCraftEvent event) {
|
||||
if (!matches(event.getInventory().getRecipe()) || event.getInventory().getResult() == null) return;
|
||||
if (!(event.getView().getPlayer() instanceof org.bukkit.entity.Player player)) {
|
||||
event.getInventory().setResult(null);
|
||||
return;
|
||||
}
|
||||
var token = REWARDS.craftingToken(states.snapshot(), player.getUniqueId());
|
||||
event.getInventory().setResult(token.map(items::create).orElse(null));
|
||||
if (token.isEmpty()) ensureReceipt(player);
|
||||
}
|
||||
|
||||
static void configureRecipe(org.bukkit.inventory.ShapedRecipe recipe) {
|
||||
recipe.shape("HCH", "NKN", "HEH");
|
||||
recipe.setIngredient('H', org.bukkit.Material.HEART_OF_THE_SEA);
|
||||
recipe.setIngredient('C', org.bukkit.Material.HEAVY_CORE);
|
||||
recipe.setIngredient('N', org.bukkit.Material.NETHER_STAR);
|
||||
recipe.setIngredient('K', org.bukkit.Material.OMINOUS_TRIAL_KEY);
|
||||
recipe.setIngredient('E', org.bukkit.Material.DRAGON_EGG);
|
||||
}
|
||||
|
||||
private static boolean matches(org.bukkit.inventory.Recipe recipe) {
|
||||
return recipe instanceof org.bukkit.Keyed keyed && keyed.getKey().equals(RECIPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public final class DaggerItems {
|
||||
private final java.util.function.Supplier<ItemStack> factory;
|
||||
public DaggerItems() { this(() -> new ItemStack(org.bukkit.Material.OMINOUS_TRIAL_KEY)); }
|
||||
DaggerItems(java.util.function.Supplier<ItemStack> factory) { this.factory = factory; }
|
||||
public ItemStack prototype() {
|
||||
var item = factory.get();
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("The Dagger", net.kyori.adventure.text.format.NamedTextColor.DARK_PURPLE));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
public ItemStack create(ItemToken token) {
|
||||
var item = prototype();
|
||||
var meta = item.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(IDENTITY, org.bukkit.persistence.PersistentDataType.STRING,
|
||||
EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static final org.bukkit.NamespacedKey IDENTITY = new org.bukkit.NamespacedKey("spigotignis", "dagger_identity");
|
||||
public boolean offer(org.bukkit.entity.Player player, ItemToken token) {
|
||||
for (var item : player.getInventory().getContents()) {
|
||||
if (identify(item).filter(token::equals).isPresent()) return true;
|
||||
}
|
||||
if (identify(player.getItemOnCursor()).filter(token::equals).isPresent()) return true;
|
||||
if (player.getInventory().firstEmpty() < 0) return false;
|
||||
return player.getInventory().addItem(create(token)).isEmpty();
|
||||
}
|
||||
|
||||
public Optional<ItemToken> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != org.bukkit.Material.OMINOUS_TRIAL_KEY || !item.hasItemMeta()) return Optional.empty();
|
||||
return EchoIdentity.decode(item.getItemMeta().getPersistentDataContainer().get(IDENTITY, org.bukkit.persistence.PersistentDataType.STRING))
|
||||
.map(token -> new ItemToken(token.owner(), token.receipt(), token.generation()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
|
||||
/** Server-thread collision policy; never loads chunks or performs a teleport. */
|
||||
public final class DaggerSafety {
|
||||
private static final long MAX_CELLS = 32768;
|
||||
private DaggerSafety() { }
|
||||
public static boolean safe(Player player, Location destination) {
|
||||
var world = player.getWorld();
|
||||
if (destination == null || destination.getWorld() != world
|
||||
|| !Double.isFinite(destination.getX()) || !Double.isFinite(destination.getY()) || !Double.isFinite(destination.getZ())) return false;
|
||||
var motion = destination.toVector().subtract(player.getLocation().toVector());
|
||||
var body = player.getBoundingBox().clone();
|
||||
var end = body.clone().shift(motion);
|
||||
if (end.getMinY() < world.getMinHeight() || end.getMaxY() > world.getMaxHeight()) return false;
|
||||
var border = world.getWorldBorder();
|
||||
for (double x : new double[]{end.getMinX(), end.getMaxX()}) {
|
||||
for (double z : new double[]{end.getMinZ(), end.getMaxZ()}) {
|
||||
if (!border.isInside(new Location(world, x, end.getMinY(), z))) return false;
|
||||
}
|
||||
}
|
||||
var swept = body.clone().union(end);
|
||||
if (swept.getMinX() <= Integer.MIN_VALUE + 2 || swept.getMaxX() >= Integer.MAX_VALUE - 2
|
||||
|| swept.getMinZ() <= Integer.MIN_VALUE + 2 || swept.getMaxZ() >= Integer.MAX_VALUE - 2) return false;
|
||||
// A one-cell halo includes shapes protruding beyond their owning block.
|
||||
int minX = (int) Math.floor(swept.getMinX()) - 1, maxX = (int) Math.floor(swept.getMaxX()) + 1;
|
||||
int minZ = (int) Math.floor(swept.getMinZ()) - 1, maxZ = (int) Math.floor(swept.getMaxZ()) + 1;
|
||||
int minY = Math.max(world.getMinHeight(), (int) Math.floor(swept.getMinY()) - 1);
|
||||
int maxY = Math.min(world.getMaxHeight() - 1, (int) Math.floor(swept.getMaxY()) + 1);
|
||||
long nx = (long) maxX - minX + 1, ny = (long) maxY - minY + 1, nz = (long) maxZ - minZ + 1;
|
||||
if (nx <= 0 || ny <= 0 || nz <= 0 || nx > MAX_CELLS || ny > MAX_CELLS || nz > MAX_CELLS || nx * ny * nz > MAX_CELLS) return false;
|
||||
for (int x = minX >> 4; x <= maxX >> 4; x++) {
|
||||
for (int z = minZ >> 4; z <= maxZ >> 4; z++) {
|
||||
if (!world.isChunkLoaded(x, z)) return false;
|
||||
}
|
||||
}
|
||||
var supportedBody = 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;
|
||||
for (var local : block.getCollisionShape().getBoundingBoxes()) {
|
||||
if (DaggerSweep.hits(body, motion, local.clone().shift(x, y, z))) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return player.wouldCollideUsing(supportedBody);
|
||||
}
|
||||
|
||||
private static boolean dangerous(Material material) {
|
||||
return switch (material) {
|
||||
case LAVA, FIRE, SOUL_FIRE, CACTUS, MAGMA_BLOCK, SWEET_BERRY_BUSH, WITHER_ROSE,
|
||||
POWDER_SNOW, POINTED_DRIPSTONE, CAMPFIRE, SOUL_CAMPFIRE -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Continuous body sweep, avoiding thin-wall gaps introduced by point sampling. */
|
||||
public final class DaggerSweep {
|
||||
private DaggerSweep() { }
|
||||
public static boolean hits(BoundingBox body, Vector motion, BoundingBox obstacle) {
|
||||
Vector center = body.getCenter();
|
||||
// Shrink by a numerical tolerance so resting on a floor is not penetration.
|
||||
double hx = Math.max(0, body.getWidthX() / 2 - 1e-7);
|
||||
double hy = Math.max(0, body.getHeight() / 2 - 1e-7);
|
||||
double hz = Math.max(0, body.getWidthZ() / 2 - 1e-7);
|
||||
double[] start = {center.getX(), center.getY(), center.getZ()};
|
||||
double[] delta = {motion.getX(), motion.getY(), motion.getZ()};
|
||||
double[] min = {obstacle.getMinX() - hx, obstacle.getMinY() - hy, obstacle.getMinZ() - hz};
|
||||
double[] max = {obstacle.getMaxX() + hx, obstacle.getMaxY() + hy, obstacle.getMaxZ() + hz};
|
||||
double enter = 0, exit = 1;
|
||||
for (int axis = 0; axis < 3; axis++) {
|
||||
if (delta[axis] == 0) {
|
||||
if (start[axis] < min[axis] || start[axis] > max[axis]) return false;
|
||||
} else {
|
||||
double a = (min[axis] - start[axis]) / delta[axis];
|
||||
double b = (max[axis] - start[axis]) / delta[axis];
|
||||
enter = Math.max(enter, Math.min(a, b));
|
||||
exit = Math.min(exit, Math.max(a, b));
|
||||
if (enter > exit) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Geometry only; visibility and world safety are supplied by the server-thread adapter. */
|
||||
public final class DaggerTargeting {
|
||||
private DaggerTargeting() { }
|
||||
public record Target(UUID id, Vector eye, boolean visible) { }
|
||||
|
||||
public static Vector forward(Vector feet, Vector direction) {
|
||||
return feet.clone().add(direction.clone().normalize().multiply(3));
|
||||
}
|
||||
public static Vector behind(Vector feet, float yaw) {
|
||||
double radians = Math.toRadians(yaw);
|
||||
return feet.clone().add(new Vector(Math.sin(radians) * 1.5, 0, -Math.cos(radians) * 1.5));
|
||||
}
|
||||
|
||||
public static Optional<Target> select(Vector eye, Vector direction, Collection<Target> candidates) {
|
||||
if (!Double.isFinite(direction.lengthSquared()) || direction.lengthSquared() <= 0) return Optional.empty();
|
||||
Vector facing = direction.clone().normalize();
|
||||
Target best = null;
|
||||
double bestAlignment = -1;
|
||||
for (Target target : candidates) {
|
||||
Vector offset = target.eye().clone().subtract(eye);
|
||||
double distanceSquared = offset.lengthSquared();
|
||||
if (!target.visible() || distanceSquared <= 0 || distanceSquared > 64) continue;
|
||||
double alignment = offset.dot(facing) / Math.sqrt(distanceSquared);
|
||||
if (alignment < Math.cos(Math.toRadians(15))) continue;
|
||||
if (alignment > bestAlignment || (alignment == bestAlignment && best != null && target.id().compareTo(best.id()) < 0)) {
|
||||
best = target;
|
||||
bestAlignment = alignment;
|
||||
}
|
||||
}
|
||||
return Optional.ofNullable(best);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.function.BiPredicate;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause;
|
||||
|
||||
public final class DaggerUse implements Listener {
|
||||
private static final TradableRewards REWARDS = new TradableRewards(Track.DAGGER);
|
||||
private final Supplier<IgnisState> states;
|
||||
private final DaggerItems items;
|
||||
private final DaggerCooldown cooldown;
|
||||
private final BiPredicate<Player, Location> safety;
|
||||
public DaggerUse(Supplier<IgnisState> states, DaggerItems items, DaggerCooldown cooldown, BiPredicate<Player, Location> safety) {
|
||||
this.states = states; this.items = items; this.cooldown = cooldown; this.safety = safety;
|
||||
}
|
||||
|
||||
// Air interactions can have block use denied by vanilla while item use remains allowed.
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onUse(PlayerInteractEvent event) {
|
||||
if (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) return;
|
||||
if (event.useItemInHand() == Event.Result.DENY
|
||||
|| event.getAction() == Action.RIGHT_CLICK_BLOCK && event.useInteractedBlock() == Event.Result.DENY) return;
|
||||
var identity = items.identify(event.getItem());
|
||||
if (identity.isEmpty()) return;
|
||||
// A Dagger must never be consumed as a vanilla vault key, including revoked copies.
|
||||
event.setUseItemInHand(Event.Result.DENY);
|
||||
event.setUseInteractedBlock(Event.Result.DENY);
|
||||
Player player = event.getPlayer();
|
||||
if (!player.isOnline() || player.isDead() || player.getGameMode() == org.bukkit.GameMode.SPECTATOR
|
||||
|| !REWARDS.valid(states.get(), player.getUniqueId(), identity.orElseThrow())
|
||||
|| !cooldown.attempt(player.getUniqueId())) return;
|
||||
var feet = player.getLocation();
|
||||
var eye = player.getEyeLocation();
|
||||
var candidates = new java.util.ArrayList<DaggerTargeting.Target>();
|
||||
var positions = new java.util.HashMap<java.util.UUID, Location>();
|
||||
for (var other : player.getWorld().getPlayers()) {
|
||||
if (other.getUniqueId().equals(player.getUniqueId()) || !other.isOnline() || other.isDead()
|
||||
|| other.isInvisible() || other.getGameMode() == org.bukkit.GameMode.SPECTATOR
|
||||
|| !player.canSee(other)) continue;
|
||||
var otherEye = other.getEyeLocation();
|
||||
if (otherEye.distanceSquared(eye) > 64 || !loadedLine(eye, otherEye)) continue;
|
||||
candidates.add(new DaggerTargeting.Target(other.getUniqueId(), otherEye.toVector(), player.hasLineOfSight(other)));
|
||||
positions.put(other.getUniqueId(), other.getLocation());
|
||||
}
|
||||
var selected = DaggerTargeting.select(eye.toVector(), eye.getDirection(), candidates);
|
||||
var point = selected.map(target -> {
|
||||
var position = positions.get(target.id());
|
||||
return DaggerTargeting.behind(position.toVector(), position.getYaw());
|
||||
}).orElseGet(() -> DaggerTargeting.forward(feet.toVector(), eye.getDirection()));
|
||||
var destination = new Location(player.getWorld(), point.getX(), point.getY(), point.getZ(), feet.getYaw(), feet.getPitch());
|
||||
if (safety.test(player, destination)) player.teleport(destination, TeleportCause.PLUGIN);
|
||||
}
|
||||
|
||||
private static boolean loadedLine(Location from, Location to) {
|
||||
for (int x = Math.min(from.getBlockX(), to.getBlockX()) >> 4; x <= Math.max(from.getBlockX(), to.getBlockX()) >> 4; x++) {
|
||||
for (int z = Math.min(from.getBlockZ(), to.getBlockZ()) >> 4; z <= Math.max(from.getBlockZ(), to.getBlockZ()) >> 4; z++) {
|
||||
if (!from.getWorld().isChunkLoaded(x, z)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
private final StateService states;
|
||||
private final EchoGameplay gameplay;
|
||||
private final InvisibilityProgress maskTracker;
|
||||
private final PearlProgress pearlTracker;
|
||||
private final Map<CommandSender, Confirmation> confirmations = new HashMap<>();
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay) {
|
||||
@@ -26,10 +27,16 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay, InvisibilityProgress maskTracker) {
|
||||
this(plugin, states, gameplay, maskTracker, null);
|
||||
}
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay,
|
||||
InvisibilityProgress maskTracker, PearlProgress pearlTracker) {
|
||||
this.plugin = plugin;
|
||||
this.states = states;
|
||||
this.gameplay = gameplay;
|
||||
this.maskTracker = maskTracker;
|
||||
this.pearlTracker = pearlTracker;
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
@@ -76,12 +83,14 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
long value = Long.parseLong(args[4]);
|
||||
boolean add = args[1].equalsIgnoreCase("add");
|
||||
CompletableFuture<IgnisState> checkpoint = track == Track.MASK && maskTracker != null
|
||||
? maskTracker.checkpoint(id) : CompletableFuture.completedFuture(states.snapshot());
|
||||
? maskTracker.checkpoint(id) : track == Track.DAGGER && pearlTracker != null
|
||||
? pearlTracker.checkpoint(id) : CompletableFuture.completedFuture(states.snapshot());
|
||||
mutation(sender, "progress " + id + " " + track + " " + args[1] + " " + value,
|
||||
checkpoint.thenCompose(ignored -> states.update(state -> ProgressAdministration.progress(state, id, track, value, add))));
|
||||
} else if (args.length == 3 && args[0].equalsIgnoreCase("give") && args[2].equalsIgnoreCase("mask")) {
|
||||
} else if (args.length == 3 && args[0].equalsIgnoreCase("give") && Set.of("mask", "dagger").contains(args[2].toLowerCase(Locale.ROOT))) {
|
||||
UUID id = resolve(args[1]), receipt = UUID.randomUUID();
|
||||
mutation(sender, "grant Mask " + id, states.update(state -> MaskRewards.issue(state, id, receipt, true)));
|
||||
var rewards = new TradableRewards(Track.valueOf(args[2].toUpperCase(Locale.ROOT)));
|
||||
mutation(sender, "grant " + args[2] + " " + id, states.update(state -> rewards.issue(state, id, receipt, true)));
|
||||
} else if (args.length == 3 && (args[0].equalsIgnoreCase("give") && args[2].equalsIgnoreCase("echo")
|
||||
|| args[0].equalsIgnoreCase("complete") && args[2].equalsIgnoreCase("void"))) {
|
||||
UUID id = resolve(args[1]), receipt = UUID.randomUUID();
|
||||
@@ -106,7 +115,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
} else throw new IllegalArgumentException("Use reset <player|UUID> <track|all> [confirm].");
|
||||
} else sender.sendMessage("Ignis admin: inspect <player|UUID>; limits [set <mask-ms|pearls|darkness-ms> <value>]; "
|
||||
+ "progress <set|add> <player|UUID> <mask|dagger|heart> <value>; complete <player|UUID> void; "
|
||||
+ "give <player|UUID> <echo|mask>; reset <player|UUID> <track|all> [confirm].");
|
||||
+ "give <player|UUID> <echo|mask|dagger>; reset <player|UUID> <track|all> [confirm].");
|
||||
} catch (IllegalArgumentException | ArithmeticException exception) {
|
||||
sender.sendMessage("Invalid Ignis command: " + exception.getMessage());
|
||||
}
|
||||
@@ -165,7 +174,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
if (args.length == 2) options = List.of("set", "add");
|
||||
else if (args.length == 4) options = List.of("mask", "dagger", "heart");
|
||||
else if (args.length == 5) options = List.of("0", "1");
|
||||
} else if (args.length == 3 && sub.equals("give")) options = List.of("echo", "mask");
|
||||
} else if (args.length == 3 && sub.equals("give")) options = List.of("echo", "mask", "dagger");
|
||||
else if (args.length == 3 && sub.equals("complete")) options = List.of("void");
|
||||
else if (sub.equals("reset")) {
|
||||
if (args.length == 3) options = List.of("void", "mask", "dagger", "heart", "all");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Receipt identity; the item's PDC key and reward service supply the track. */
|
||||
public record ItemToken(UUID issuer, UUID receipt, long generation) {
|
||||
public ItemToken {
|
||||
Objects.requireNonNull(issuer);
|
||||
Objects.requireNonNull(receipt);
|
||||
if (generation < 0) throw new IllegalArgumentException("Invalid item generation.");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public final class MaskCrafting implements Listener, Runnable {
|
||||
private final EchoGameplay dispatch;
|
||||
private final Set<UUID> preparing = new HashSet<>();
|
||||
private final Set<MaskToken> delivering = new HashSet<>();
|
||||
private final Set<MaskToken> offered = new HashSet<>();
|
||||
private boolean registered;
|
||||
private final java.util.function.Function<org.bukkit.inventory.ItemStack, ShapedRecipe> recipeFactory;
|
||||
|
||||
@@ -90,10 +91,15 @@ public final class MaskCrafting implements Listener, Runnable {
|
||||
for (var entry : state.pendingRewards().entrySet()) {
|
||||
if (entry.getValue() != Track.MASK) continue;
|
||||
var token = new MaskToken(player.getUniqueId(), entry.getKey(), state.track(Track.MASK).generation());
|
||||
if (delivering.contains(token) || !items.offer(player, token)) continue;
|
||||
if (delivering.contains(token)) continue;
|
||||
if (!offered.contains(token) && !items.offer(player, token)) continue;
|
||||
offered.add(token);
|
||||
delivering.add(token);
|
||||
states.update(current -> MaskRewards.acknowledge(current, token))
|
||||
.whenComplete((ignored, error) -> dispatch.onServer(() -> delivering.remove(token)));
|
||||
.whenComplete((ignored, error) -> dispatch.onServer(() -> {
|
||||
delivering.remove(token);
|
||||
if (error == null) offered.remove(token);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,26 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Crafting receipts are durable before vanilla may offer a result or consume ingredients. */
|
||||
/** Compatibility facade preserving existing Mask tokens and persisted identities. */
|
||||
public final class MaskRewards {
|
||||
private static final TradableRewards REWARDS = new TradableRewards(Track.MASK);
|
||||
private MaskRewards() { }
|
||||
|
||||
public static IgnisState issue(IgnisState state, UUID issuer, UUID receipt, boolean adminGrant) {
|
||||
PlayerState player = state.players().getOrDefault(issuer, PlayerState.empty());
|
||||
TrackState previous = player.track(Track.MASK);
|
||||
if (!adminGrant && !previous.unlocked()) throw new IllegalArgumentException("Mask crafting is not unlocked.");
|
||||
if (player.issuedRewards().containsKey(receipt)) {
|
||||
if (player.issuedRewards().get(receipt) != Track.MASK) throw new IllegalArgumentException("Receipt is already used.");
|
||||
return state;
|
||||
}
|
||||
var tracks = new HashMap<>(player.tracks());
|
||||
tracks.put(Track.MASK, new TrackState(previous.progress(), true, previous.activated(), previous.generation()));
|
||||
var issued = new HashMap<>(player.issuedRewards());
|
||||
issued.put(receipt, Track.MASK);
|
||||
var pending = new HashMap<>(player.pendingRewards());
|
||||
if (adminGrant) pending.put(receipt, Track.MASK);
|
||||
return replace(state, issuer, new PlayerState(tracks, pending, issued));
|
||||
return REWARDS.issue(state, issuer, receipt, adminGrant);
|
||||
}
|
||||
|
||||
public static boolean valid(IgnisState state, UUID holder, MaskToken token) {
|
||||
PlayerState origin = state.players().getOrDefault(token.issuer(), PlayerState.empty());
|
||||
return state.players().getOrDefault(holder, PlayerState.empty()).track(Track.MASK).unlocked()
|
||||
&& origin.track(Track.MASK).unlocked()
|
||||
&& origin.track(Track.MASK).generation() == token.generation()
|
||||
&& origin.issuedRewards().get(token.receipt()) == Track.MASK;
|
||||
return REWARDS.valid(state, holder, convert(token));
|
||||
}
|
||||
|
||||
public static Optional<MaskToken> craftingToken(IgnisState state, UUID issuer) {
|
||||
PlayerState player = state.players().getOrDefault(issuer, PlayerState.empty());
|
||||
if (!player.track(Track.MASK).unlocked()) return Optional.empty();
|
||||
return player.issuedRewards().entrySet().stream().filter(entry -> entry.getValue() == Track.MASK)
|
||||
.filter(entry -> !player.pendingRewards().containsKey(entry.getKey()))
|
||||
.map(java.util.Map.Entry::getKey).sorted().findFirst()
|
||||
.map(id -> new MaskToken(issuer, id, player.track(Track.MASK).generation()));
|
||||
return REWARDS.craftingToken(state, issuer).map(token -> new MaskToken(token.issuer(), token.receipt(), token.generation()));
|
||||
}
|
||||
|
||||
public static IgnisState acknowledge(IgnisState state, MaskToken token) {
|
||||
if (!valid(state, token.issuer(), token)) return state;
|
||||
PlayerState player = state.players().get(token.issuer());
|
||||
var pending = new HashMap<>(player.pendingRewards());
|
||||
pending.remove(token.receipt());
|
||||
return replace(state, token.issuer(), new PlayerState(player.tracks(), pending, player.issuedRewards()));
|
||||
return REWARDS.acknowledge(state, convert(token));
|
||||
}
|
||||
|
||||
private static IgnisState replace(IgnisState state, UUID issuer, PlayerState player) {
|
||||
var players = new HashMap<>(state.players());
|
||||
players.put(issuer, player);
|
||||
return new IgnisState(state.limits(), players);
|
||||
private static ItemToken convert(MaskToken token) {
|
||||
return new ItemToken(token.issuer(), token.receipt(), token.generation());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerStatisticIncrementEvent;
|
||||
|
||||
/** Correlates a launch with the post-consumption vanilla USE_ITEM notification. */
|
||||
public final class PearlLaunchListener implements Listener {
|
||||
private record Probe(PlayerLaunchProjectileEvent event, int before) { }
|
||||
private final java.util.Map<UUID, java.util.Deque<Probe>> pending = new java.util.HashMap<>();
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<UUID> credit;
|
||||
public PearlLaunchListener(Consumer<Runnable> nextTick, Consumer<UUID> credit) {
|
||||
this.nextTick = nextTick;
|
||||
this.credit = credit;
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onLaunch(PlayerLaunchProjectileEvent event) {
|
||||
if (event.isCancelled() || !(event.getProjectile() instanceof org.bukkit.entity.EnderPearl)
|
||||
|| event.getItemStack().getType() != org.bukkit.Material.ENDER_PEARL) return;
|
||||
UUID player = event.getPlayer().getUniqueId();
|
||||
var probe = new Probe(event, event.getItemStack().getAmount());
|
||||
pending.computeIfAbsent(player, ignored -> new java.util.ArrayDeque<>()).addFirst(probe);
|
||||
nextTick.accept(() -> {
|
||||
var probes = pending.get(player);
|
||||
if (probes != null) {
|
||||
probes.remove(probe);
|
||||
if (probes.isEmpty()) pending.remove(player);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void onUse(PlayerStatisticIncrementEvent event) {
|
||||
if (event.getStatistic() != org.bukkit.Statistic.USE_ITEM || event.getMaterial() != org.bukkit.Material.ENDER_PEARL) return;
|
||||
UUID player = event.getPlayer().getUniqueId();
|
||||
var probes = pending.get(player);
|
||||
if (probes == null) return;
|
||||
var iterator = probes.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Probe probe = iterator.next();
|
||||
if (probe.event().isCancelled() || !probe.event().shouldConsume()
|
||||
|| probe.event().getItemStack().getAmount() >= probe.before()) continue;
|
||||
iterator.remove();
|
||||
if (probes.isEmpty()) pending.remove(player);
|
||||
credit.accept(player);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/** Buffers observed consumed throws until acknowledged by the persistence worker. */
|
||||
public final class PearlProgress {
|
||||
private final StateService states;
|
||||
public PearlProgress(StateService states) { this.states = states; }
|
||||
private static final class Buffer {
|
||||
final long generation;
|
||||
long count;
|
||||
CompletableFuture<IgnisState> saving;
|
||||
Buffer(long generation) { this.generation = generation; }
|
||||
}
|
||||
private final java.util.Map<UUID, Buffer> pending = new java.util.HashMap<>();
|
||||
private Buffer buffer(UUID player) {
|
||||
long generation = generation(states.snapshot(), player);
|
||||
return pending.compute(player, (ignored, old) -> old == null || old.generation != generation ? new Buffer(generation) : old);
|
||||
}
|
||||
private static long generation(IgnisState state, UUID player) {
|
||||
return state.players().getOrDefault(player, PlayerState.empty()).track(Track.DAGGER).generation();
|
||||
}
|
||||
public synchronized void record(UUID player) {
|
||||
Buffer buffer = buffer(player);
|
||||
buffer.count = Math.addExact(buffer.count, 1);
|
||||
}
|
||||
public synchronized CompletableFuture<Void> checkpointAll() {
|
||||
return CompletableFuture.allOf(new java.util.ArrayList<>(pending.keySet()).stream()
|
||||
.map(this::checkpoint).toArray(CompletableFuture<?>[]::new));
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<IgnisState> checkpoint(UUID player) {
|
||||
Buffer buffer = buffer(player);
|
||||
if (buffer.saving != null) return buffer.saving.thenCompose(ignored -> checkpoint(player));
|
||||
long count = buffer.count;
|
||||
buffer.count = 0;
|
||||
if (count == 0) {
|
||||
pending.remove(player, buffer);
|
||||
return CompletableFuture.completedFuture(states.snapshot());
|
||||
}
|
||||
var acknowledgement = new CompletableFuture<IgnisState>();
|
||||
buffer.saving = acknowledgement;
|
||||
states.update(state -> generation(state, player) == buffer.generation
|
||||
? ProgressAdministration.progress(state, player, Track.DAGGER, count, true) : state)
|
||||
.whenComplete((state, error) -> {
|
||||
synchronized (this) {
|
||||
if (error != null) buffer.count = Math.addExact(buffer.count, count);
|
||||
buffer.saving = null;
|
||||
}
|
||||
if (error != null) acknowledgement.completeExceptionally(error);
|
||||
else acknowledgement.complete(state);
|
||||
});
|
||||
return acknowledgement;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
private MaskCrafting maskCrafting;
|
||||
private MaskEffects maskEffects;
|
||||
private MaskEquipmentListener maskEquipment;
|
||||
private PearlProgress pearlProgress;
|
||||
private DaggerCrafting daggerCrafting;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean pearlSaveWarning = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -41,13 +44,27 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
getServer().getScheduler().runTaskTimer(this, new MaskSkinLoader(this, maskItems, gameplay), 1L, 6000L);
|
||||
getServer().getScheduler().runTaskTimer(this, maskCrafting, 1L, 20L);
|
||||
getServer().getScheduler().runTaskTimer(this, maskEffects, 1L, 1L);
|
||||
var commands = new IgnisCommands(this, service, gameplay, maskTracker);
|
||||
pearlProgress = new PearlProgress(service);
|
||||
var pearlListener = new PearlLaunchListener(action -> getServer().getScheduler().runTask(this, action), id -> {
|
||||
pearlProgress.record(id);
|
||||
pearlProgress.checkpoint(id).whenComplete((saved, failure) -> reportPearlSave(failure));
|
||||
});
|
||||
getServer().getPluginManager().registerEvents(pearlListener, this);
|
||||
getServer().getScheduler().runTaskTimer(this,
|
||||
() -> pearlProgress.checkpointAll().whenComplete((saved, failure) -> reportPearlSave(failure)), 20L, 20L);
|
||||
var daggerItems = new DaggerItems();
|
||||
daggerCrafting = new DaggerCrafting(this, service, daggerItems, gameplay);
|
||||
getServer().getPluginManager().registerEvents(daggerCrafting, this);
|
||||
getServer().getPluginManager().registerEvents(new DaggerUse(service::snapshot, daggerItems,
|
||||
new DaggerCooldown(System::nanoTime), DaggerSafety::safe), this);
|
||||
getServer().getScheduler().runTaskTimer(this, daggerCrafting, 1L, 20L);
|
||||
var commands = new IgnisCommands(this, service, gameplay, maskTracker, pearlProgress);
|
||||
for (String name : java.util.List.of("ignis", "ignisadmin")) {
|
||||
var command = java.util.Objects.requireNonNull(getCommand(name), "Missing command " + name);
|
||||
command.setExecutor(commands);
|
||||
command.setTabCompleter(commands);
|
||||
}
|
||||
getLogger().info("Ignis Echo and Mask enabled. Dagger and Heart remain in development.");
|
||||
getLogger().info("Ignis Echo, Mask and Dagger enabled. Heart remains in development.");
|
||||
});
|
||||
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
|
||||
service.close();
|
||||
@@ -58,10 +75,25 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (maskEffects != null) maskEffects.close();
|
||||
if (maskEquipment != null) com.comphenix.protocol.ProtocolLibrary.getProtocolManager().removePacketListener(maskEquipment);
|
||||
if (maskCrafting != null) maskCrafting.close();
|
||||
if (maskProgress != null) maskProgress.stop();
|
||||
if (initialization != null) initialization.thenAccept(StateService::close);
|
||||
try {
|
||||
if (maskEffects != null) maskEffects.close();
|
||||
if (maskEquipment != null) com.comphenix.protocol.ProtocolLibrary.getProtocolManager().removePacketListener(maskEquipment);
|
||||
if (maskCrafting != null) maskCrafting.close();
|
||||
if (daggerCrafting != null) daggerCrafting.close();
|
||||
if (maskProgress != null) maskProgress.stop();
|
||||
} finally {
|
||||
var drain = pearlProgress == null ? CompletableFuture.<Void>completedFuture(null) : pearlProgress.checkpointAll();
|
||||
drain.whenComplete((saved, failure) -> {
|
||||
reportPearlSave(failure);
|
||||
if (initialization != null) initialization.thenAccept(StateService::close);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void reportPearlSave(Throwable failure) {
|
||||
if (failure == null) pearlSaveWarning.set(false);
|
||||
else if (pearlSaveWarning.compareAndSet(false, true)) {
|
||||
getLogger().warning("Pearl progress save failed; unacknowledged credit remains in memory for retry while enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Track-specific durable issuance and personal-use validation for tradable rewards. */
|
||||
public final class TradableRewards {
|
||||
private final Track track;
|
||||
public TradableRewards(Track track) {
|
||||
if (track == null || track == Track.VOID) throw new IllegalArgumentException("Echo uses bound activation rewards.");
|
||||
this.track = track;
|
||||
}
|
||||
|
||||
public IgnisState issue(IgnisState state, UUID issuer, UUID receipt, boolean adminGrant) {
|
||||
PlayerState player = state.players().getOrDefault(issuer, PlayerState.empty());
|
||||
TrackState previous = player.track(track);
|
||||
if (!adminGrant && !previous.unlocked()) throw new IllegalArgumentException("Crafting is not unlocked.");
|
||||
if (player.issuedRewards().containsKey(receipt)) {
|
||||
if (player.issuedRewards().get(receipt) != track) throw new IllegalArgumentException("Receipt is already used.");
|
||||
return state;
|
||||
}
|
||||
var tracks = new HashMap<>(player.tracks());
|
||||
tracks.put(track, new TrackState(previous.progress(), true, previous.activated(), previous.generation()));
|
||||
var issued = new HashMap<>(player.issuedRewards());
|
||||
issued.put(receipt, track);
|
||||
var pending = new HashMap<>(player.pendingRewards());
|
||||
if (adminGrant) pending.put(receipt, track);
|
||||
return replace(state, issuer, new PlayerState(tracks, pending, issued));
|
||||
}
|
||||
|
||||
public boolean valid(IgnisState state, UUID holder, ItemToken token) {
|
||||
PlayerState origin = state.players().getOrDefault(token.issuer(), PlayerState.empty());
|
||||
return state.players().getOrDefault(holder, PlayerState.empty()).track(track).unlocked()
|
||||
&& origin.track(track).unlocked()
|
||||
&& origin.track(track).generation() == token.generation()
|
||||
&& origin.issuedRewards().get(token.receipt()) == track;
|
||||
}
|
||||
|
||||
public Optional<ItemToken> craftingToken(IgnisState state, UUID issuer) {
|
||||
PlayerState player = state.players().getOrDefault(issuer, PlayerState.empty());
|
||||
if (!player.track(track).unlocked()) return Optional.empty();
|
||||
return player.issuedRewards().entrySet().stream().filter(entry -> entry.getValue() == track)
|
||||
.filter(entry -> !player.pendingRewards().containsKey(entry.getKey()))
|
||||
.map(java.util.Map.Entry::getKey).sorted().findFirst()
|
||||
.map(id -> new ItemToken(issuer, id, player.track(track).generation()));
|
||||
}
|
||||
|
||||
public IgnisState acknowledge(IgnisState state, ItemToken token) {
|
||||
if (!valid(state, token.issuer(), token)) return state;
|
||||
PlayerState player = state.players().get(token.issuer());
|
||||
var pending = new HashMap<>(player.pendingRewards());
|
||||
pending.remove(token.receipt());
|
||||
return replace(state, token.issuer(), new PlayerState(player.tracks(), pending, player.issuedRewards()));
|
||||
}
|
||||
|
||||
private static IgnisState replace(IgnisState state, UUID issuer, PlayerState player) {
|
||||
var players = new HashMap<>(state.players());
|
||||
players.put(issuer, player);
|
||||
return new IgnisState(state.limits(), players);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ name: SpigotIgnis
|
||||
version: '${version}'
|
||||
main: games.dmg.spigotignis.SpigotIgnisPlugin
|
||||
api-version: '1.21'
|
||||
description: Earnable shadow progression with Echo of the Abyss and The Mask.
|
||||
description: Earnable shadow progression with Echo of the Abyss, The Mask, and The Dagger.
|
||||
depend: [ProtocolLib]
|
||||
commands:
|
||||
ignis:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerCooldownTest {
|
||||
@Test
|
||||
void failedAttemptStillGatesEveryCopyForTwoSecondsWithoutExtendingOnSpam() {
|
||||
var time = new AtomicLong();
|
||||
var cooldown = new DaggerCooldown(time::get);
|
||||
var player = UUID.randomUUID();
|
||||
assertTrue(cooldown.attempt(player));
|
||||
assertFalse(cooldown.attempt(player));
|
||||
assertTrue(cooldown.attempt(UUID.randomUUID()));
|
||||
time.set(1_999_999_999L);
|
||||
assertFalse(cooldown.attempt(player));
|
||||
time.incrementAndGet();
|
||||
assertTrue(cooldown.attempt(player));
|
||||
assertFalse(cooldown.attempt(player));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerCraftingTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
|
||||
@Test
|
||||
void discoversUnlockedRecipeIssuesSeparateCraftReceiptAndAcknowledgesDeliveredGift() throws Exception {
|
||||
var plugin = mock(org.bukkit.plugin.java.JavaPlugin.class);
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
var id = java.util.UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(server.addRecipe(recipe)).thenReturn(true);
|
||||
var items = mock(DaggerItems.class);
|
||||
var gift = java.util.UUID.randomUUID();
|
||||
when(items.offer(player, new ItemToken(id, gift, 0))).thenReturn(true);
|
||||
var dispatch = mock(EchoGameplay.class);
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
var rewards = new TradableRewards(Track.DAGGER);
|
||||
states.update(state -> rewards.issue(state, id, gift, true)).get();
|
||||
var crafting = new DaggerCrafting(plugin, states, items, dispatch, ignored -> recipe);
|
||||
crafting.run();
|
||||
states.update(state -> state).get();
|
||||
verify(server).addRecipe(recipe);
|
||||
verify(player).discoverRecipe(DaggerCrafting.RECIPE);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(states.snapshot().players().get(id).pendingRewards().isEmpty());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(2, states.snapshot().players().get(id).issuedRewards().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewRequiresDurablePersonalReceiptAndResetRevokesIt() throws Exception {
|
||||
var id = java.util.UUID.randomUUID();
|
||||
var receipt = java.util.UUID.randomUUID();
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(recipe.getKey()).thenReturn(DaggerCrafting.RECIPE);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
when(inventory.getRecipe()).thenReturn(recipe);
|
||||
when(inventory.getResult()).thenReturn(mock(org.bukkit.inventory.ItemStack.class));
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getPlayer()).thenReturn(player);
|
||||
var event = mock(org.bukkit.event.inventory.PrepareItemCraftEvent.class);
|
||||
when(event.getInventory()).thenReturn(inventory);
|
||||
when(event.getView()).thenReturn(view);
|
||||
var items = mock(DaggerItems.class);
|
||||
var product = mock(org.bukkit.inventory.ItemStack.class);
|
||||
when(items.create(new ItemToken(id, receipt, 0))).thenReturn(product);
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
var rewards = new TradableRewards(Track.DAGGER);
|
||||
states.update(state -> rewards.issue(ProgressAdministration.progress(state, id, Track.DAGGER, 313, false), id, receipt, false)).get();
|
||||
var crafting = new DaggerCrafting(null, states, items, null);
|
||||
crafting.onPrepare(event);
|
||||
verify(inventory).setResult(product);
|
||||
states.update(state -> ProgressAdministration.reset(state, id, java.util.Set.of(Track.DAGGER))).get();
|
||||
crafting.onPrepare(event);
|
||||
verify(inventory).setResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainsExactExpensiveRecipeWithoutIngredientSubstitutions() {
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
DaggerCrafting.configureRecipe(recipe);
|
||||
verify(recipe).shape("HCH", "NKN", "HEH");
|
||||
verify(recipe).setIngredient('H', org.bukkit.Material.HEART_OF_THE_SEA);
|
||||
verify(recipe).setIngredient('C', org.bukkit.Material.HEAVY_CORE);
|
||||
verify(recipe).setIngredient('N', org.bukkit.Material.NETHER_STAR);
|
||||
verify(recipe).setIngredient('K', org.bukkit.Material.OMINOUS_TRIAL_KEY);
|
||||
verify(recipe).setIngredient('E', org.bukkit.Material.DRAGON_EGG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthenticatedOutputsAndAutomatedCraftersCannotConsumeIngredients() {
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(recipe.getKey()).thenReturn(DaggerCrafting.RECIPE);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
var craft = mock(CraftItemEvent.class);
|
||||
when(craft.getRecipe()).thenReturn(recipe);
|
||||
when(craft.getInventory()).thenReturn(inventory);
|
||||
var automatic = mock(CrafterCraftEvent.class);
|
||||
when(automatic.getRecipe()).thenReturn(recipe);
|
||||
var crafting = new DaggerCrafting(null, null, new DaggerItems(), null);
|
||||
crafting.onCraft(craft);
|
||||
crafting.onCrafter(automatic);
|
||||
verify(craft).setCancelled(true);
|
||||
verify(inventory).setResult(null);
|
||||
verify(inventory, never()).setMatrix(any());
|
||||
verify(automatic).setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerItemsTest {
|
||||
@Test
|
||||
void fullInventoryDefersGiftAndExistingCursorReceiptPreventsDuplicateDelivery() {
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
var inventory = mock(org.bukkit.inventory.PlayerInventory.class);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getContents()).thenReturn(new ItemStack[0]);
|
||||
when(inventory.firstEmpty()).thenReturn(-1);
|
||||
var item = mock(ItemStack.class);
|
||||
var meta = mock(ItemMeta.class);
|
||||
var tags = mock(PersistentDataContainer.class);
|
||||
when(item.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getPersistentDataContainer()).thenReturn(tags);
|
||||
var token = new ItemToken(UUID.randomUUID(), UUID.randomUUID(), 0);
|
||||
var items = new DaggerItems(() -> item);
|
||||
assertFalse(items.offer(player, token));
|
||||
when(inventory.firstEmpty()).thenReturn(0);
|
||||
when(inventory.addItem(item)).thenReturn(new java.util.HashMap<>());
|
||||
assertTrue(items.offer(player, token));
|
||||
when(item.getType()).thenReturn(Material.OMINOUS_TRIAL_KEY);
|
||||
when(item.hasItemMeta()).thenReturn(true);
|
||||
when(tags.get(new NamespacedKey("spigotignis", "dagger_identity"), PersistentDataType.STRING))
|
||||
.thenReturn(EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), 0)));
|
||||
when(player.getItemOnCursor()).thenReturn(item);
|
||||
when(inventory.firstEmpty()).thenReturn(-1);
|
||||
assertTrue(items.offer(player, token));
|
||||
verify(inventory, times(1)).addItem(item);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsNamedAuthenticatedReusableKey() {
|
||||
var item = mock(ItemStack.class);
|
||||
var meta = mock(ItemMeta.class);
|
||||
var tags = mock(PersistentDataContainer.class);
|
||||
when(item.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getPersistentDataContainer()).thenReturn(tags);
|
||||
var token = new ItemToken(UUID.randomUUID(), UUID.randomUUID(), 4);
|
||||
var items = new DaggerItems(() -> item);
|
||||
assertSame(item, items.create(token));
|
||||
verify(meta).displayName(net.kyori.adventure.text.Component.text("The Dagger", net.kyori.adventure.text.format.NamedTextColor.DARK_PURPLE));
|
||||
verify(tags).set(new NamespacedKey("spigotignis", "dagger_identity"), PersistentDataType.STRING,
|
||||
EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
|
||||
verify(item, never()).setAmount(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyOminousKeysWithDaggerIdentityParseAsDaggerItems() {
|
||||
var items = new DaggerItems();
|
||||
var item = mock(ItemStack.class);
|
||||
var meta = mock(ItemMeta.class);
|
||||
var tags = mock(PersistentDataContainer.class);
|
||||
when(item.getType()).thenReturn(Material.OMINOUS_TRIAL_KEY);
|
||||
when(item.hasItemMeta()).thenReturn(true);
|
||||
when(item.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getPersistentDataContainer()).thenReturn(tags);
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
UUID issuer = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
when(tags.get(new NamespacedKey("spigotignis", "dagger_identity"), PersistentDataType.STRING))
|
||||
.thenReturn(EchoIdentity.encode(new EchoToken(issuer, receipt, 3)));
|
||||
assertEquals(new ItemToken(issuer, receipt, 3), items.identify(item).orElseThrow());
|
||||
when(item.getType()).thenReturn(Material.TRIAL_KEY);
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldBorder;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.VoxelShape;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerSafetyTest {
|
||||
@Test
|
||||
void requiresClearLoadedSupportedInBorderDestinationWithoutMutatingPlayerBounds() {
|
||||
var world = mock(World.class);
|
||||
var border = mock(WorldBorder.class);
|
||||
when(world.getWorldBorder()).thenReturn(border);
|
||||
when(border.isInside(any())).thenReturn(true);
|
||||
when(world.getMinHeight()).thenReturn(-64);
|
||||
when(world.getMaxHeight()).thenReturn(320);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var air = mock(Block.class);
|
||||
var shape = mock(VoxelShape.class);
|
||||
when(air.getType()).thenReturn(Material.AIR);
|
||||
when(air.getCollisionShape()).thenReturn(shape);
|
||||
when(shape.getBoundingBoxes()).thenReturn(java.util.List.of());
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(air);
|
||||
var player = mock(Player.class);
|
||||
var body = new BoundingBox(-.3, 64, -.3, .3, 65.8, .3);
|
||||
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);
|
||||
var protrudingShape = mock(VoxelShape.class);
|
||||
when(protruding.getType()).thenReturn(Material.STONE);
|
||||
when(protruding.getCollisionShape()).thenReturn(protrudingShape);
|
||||
when(protrudingShape.getBoundingBoxes()).thenReturn(java.util.List.of(new BoundingBox(-1, 0, 0, 0, 1, 1)));
|
||||
when(world.getBlockAt(1, 64, 0)).thenReturn(protruding);
|
||||
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);
|
||||
when(air.getType()).thenReturn(Material.LAVA);
|
||||
assertFalse(DaggerSafety.safe(player, destination));
|
||||
when(air.getType()).thenReturn(Material.AIR);
|
||||
when(shape.getBoundingBoxes()).thenReturn(java.util.List.of(new BoundingBox(0, 0, 0, 1, 1, 1)));
|
||||
assertFalse(DaggerSafety.safe(player, destination));
|
||||
when(shape.getBoundingBoxes()).thenReturn(java.util.List.of());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
clearInvocations(world);
|
||||
assertFalse(DaggerSafety.safe(player, destination));
|
||||
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(border.isInside(any())).thenReturn(false);
|
||||
assertFalse(DaggerSafety.safe(player, destination));
|
||||
assertFalse(DaggerSafety.safe(player, new Location(world, Double.NaN, 64, 0)));
|
||||
assertFalse(DaggerSafety.safe(player, new Location(world, 0, 320, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerSweepTest {
|
||||
@Test
|
||||
void continuousSweepCatchesThinWallsButNotFloorContactOrOffPathObstacles() {
|
||||
var body = new BoundingBox(-.3, 0, -.3, .3, 1.8, .3);
|
||||
assertTrue(DaggerSweep.hits(body, new Vector(3, 0, 0), new BoundingBox(1.01, 0, -1, 1.02, 3, 1)));
|
||||
assertTrue(DaggerSweep.hits(body, new Vector(3, 0, 0), new BoundingBox(3, 0, -1, 4, 3, 1)));
|
||||
assertFalse(DaggerSweep.hits(body, new Vector(3, 0, 0), new BoundingBox(-1, -1, -1, 5, 0, 1)));
|
||||
assertFalse(DaggerSweep.hits(body, new Vector(3, 0, 3), new BoundingBox(0, 0, 2, .5, 2, 3)));
|
||||
assertTrue(DaggerSweep.hits(body, new Vector(), new BoundingBox(0, 0, 0, 1, 1, 1)));
|
||||
assertEquals(new BoundingBox(-.3, 0, -.3, .3, 1.8, .3), body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerTargetingTest {
|
||||
@Test
|
||||
void forwardIsThreeBlocksAlongAimAndBehindIsHorizontalRelativeToTargetYaw() {
|
||||
var feet = new Vector(10, 64, 10);
|
||||
assertEquals(new Vector(10, 64, 13), DaggerTargeting.forward(feet, new Vector(0, 0, 2)));
|
||||
assertEquals(new Vector(10, 67, 10), DaggerTargeting.forward(feet, new Vector(0, 1, 0)));
|
||||
assertEquals(new Vector(10, 64, 8.5), DaggerTargeting.behind(feet, 0));
|
||||
assertEquals(11.5, DaggerTargeting.behind(feet, 90).getX(), 1e-10);
|
||||
assertEquals(64, DaggerTargeting.behind(feet, 90).getY());
|
||||
assertEquals(new Vector(10, 64, 10), feet);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tiesUseUuidRatherThanIterationOrderAndInvalidAimHasNoTarget() {
|
||||
var first = new DaggerTargeting.Target(new UUID(0, 1), new Vector(0, 0, 2), true);
|
||||
var second = new DaggerTargeting.Target(new UUID(0, 2), new Vector(0, 0, 4), true);
|
||||
assertEquals(first, DaggerTargeting.select(new Vector(), new Vector(0, 0, 1), List.of(second, first)).orElseThrow());
|
||||
assertEquals(first, DaggerTargeting.select(new Vector(), new Vector(0, 0, 1), List.of(first, second)).orElseThrow());
|
||||
assertTrue(DaggerTargeting.select(new Vector(), new Vector(), List.of(first)).isEmpty());
|
||||
assertTrue(DaggerTargeting.select(new Vector(), new Vector(Double.NaN, 0, 1), List.of(first)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectsClosestCrosshairVisiblePlayerInsideEightBlockThreeDimensionalCone() {
|
||||
var centered = new DaggerTargeting.Target(new UUID(0, 2), new Vector(0, 0, 8), true);
|
||||
var nearButOffCenter = new DaggerTargeting.Target(new UUID(0, 1), new Vector(.4, 0, 2), true);
|
||||
var blocked = new DaggerTargeting.Target(new UUID(0, 0), new Vector(0, 0, 1), false);
|
||||
var outsideRange = new DaggerTargeting.Target(new UUID(0, 0), new Vector(0, 0, 8.01), true);
|
||||
var outsideCone = new DaggerTargeting.Target(new UUID(0, 0), new Vector(0, 2, 2), true);
|
||||
var behind = new DaggerTargeting.Target(new UUID(0, 0), new Vector(0, 0, -1), true);
|
||||
var origin = new Vector();
|
||||
var direction = new Vector(0, 0, 3);
|
||||
assertEquals(centered, DaggerTargeting.select(origin, direction,
|
||||
List.of(blocked, outsideRange, outsideCone, behind, nearButOffCenter, centered)).orElseThrow());
|
||||
assertTrue(DaggerTargeting.select(origin, direction, List.of(blocked, outsideRange, outsideCone, behind)).isEmpty());
|
||||
assertEquals(new Vector(0, 0, 3), direction, "Caller vectors must not be mutated");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DaggerUseTest {
|
||||
@Test
|
||||
void visibleTargetUsesBehindDestinationWithoutUnsafeFallbackAndHonorsItemVetoes() {
|
||||
var player = mock(Player.class);
|
||||
var target = mock(Player.class);
|
||||
var id = UUID.randomUUID();
|
||||
var receipt = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(target.isOnline()).thenReturn(true);
|
||||
when(player.canSee(target)).thenReturn(true);
|
||||
when(player.hasLineOfSight(target)).thenReturn(true);
|
||||
var world = mock(World.class);
|
||||
when(world.getPlayers()).thenReturn(java.util.List.of(player, target));
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(player.getEyeLocation()).thenReturn(new Location(world, 0, 65.62, 0));
|
||||
when(target.getLocation()).thenReturn(new Location(world, 0, 64, 5));
|
||||
when(target.getEyeLocation()).thenReturn(new Location(world, 0, 65.62, 5));
|
||||
var item = mock(ItemStack.class);
|
||||
var items = mock(DaggerItems.class);
|
||||
when(items.identify(item)).thenReturn(java.util.Optional.of(new ItemToken(id, receipt, 0)));
|
||||
var state = new TradableRewards(Track.DAGGER).issue(IgnisState.empty(), id, receipt, true);
|
||||
var checked = new AtomicReference<Location>();
|
||||
var allowed = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
var clock = new AtomicLong();
|
||||
var use = new DaggerUse(() -> state, items, new DaggerCooldown(clock::get), (actor, destination) -> {
|
||||
checked.set(destination); return allowed.get();
|
||||
});
|
||||
var event = mock(PlayerInteractEvent.class);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getItem()).thenReturn(item);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
|
||||
when(event.useItemInHand()).thenReturn(Event.Result.DENY);
|
||||
use.onUse(event);
|
||||
assertNull(checked.get());
|
||||
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
|
||||
use.onUse(event);
|
||||
assertEquals(new Location(world, 0, 64, 3.5), checked.get());
|
||||
verify(player, never()).teleport(any(Location.class), any(TeleportCause.class));
|
||||
allowed.set(true);
|
||||
clock.set(2_000_000_000L);
|
||||
when(target.isInvisible()).thenReturn(true);
|
||||
use.onUse(event);
|
||||
verify(player).teleport(new Location(world, 0, 64, 3), TeleportCause.PLUGIN);
|
||||
when(target.isInvisible()).thenReturn(false);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
clock.set(4_000_000_000L);
|
||||
clearInvocations(player);
|
||||
use.onUse(event);
|
||||
verify(player, never()).hasLineOfSight(target);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rightClickAirUsesPluginTeleportRetainsItemAndKeepsCooldownOnCancellation() {
|
||||
var player = mock(Player.class);
|
||||
UUID id = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
var world = mock(World.class);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(player.getEyeLocation()).thenReturn(new Location(world, 0, 65.62, 0));
|
||||
var item = mock(ItemStack.class);
|
||||
var items = mock(DaggerItems.class);
|
||||
when(items.identify(item)).thenReturn(java.util.Optional.of(new ItemToken(id, receipt, 0)));
|
||||
var state = new AtomicReference<>(new TradableRewards(Track.DAGGER).issue(IgnisState.empty(), id, receipt, true));
|
||||
var time = new AtomicLong();
|
||||
var use = new DaggerUse(state::get, items, new DaggerCooldown(time::get), (actor, destination) -> true);
|
||||
var event = mock(PlayerInteractEvent.class);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getItem()).thenReturn(item);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
|
||||
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
|
||||
when(event.useInteractedBlock()).thenReturn(Event.Result.DENY); // Default air interaction, not an item veto.
|
||||
use.onUse(event); use.onUse(event);
|
||||
verify(player, times(1)).teleport(new Location(world, 0, 64, 3), TeleportCause.PLUGIN);
|
||||
verify(event, atLeastOnce()).setUseItemInHand(Event.Result.DENY);
|
||||
verify(event, atLeastOnce()).setUseInteractedBlock(Event.Result.DENY);
|
||||
verify(item, never()).setAmount(anyInt());
|
||||
time.set(2_000_000_000L);
|
||||
state.set(ProgressAdministration.reset(state.get(), id, java.util.Set.of(Track.DAGGER)));
|
||||
use.onUse(event);
|
||||
verify(player, times(1)).teleport(any(Location.class), eq(TeleportCause.PLUGIN));
|
||||
assertFalse(state.get().players().get(id).track(Track.DAGGER).unlocked());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,31 @@ import org.junit.jupiter.api.Test;
|
||||
class IgnisCommandsTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
|
||||
|
||||
@Test
|
||||
void administrativePearlSetFlushesOlderCreditsBeforeOverwritingCounter() throws Exception {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
var sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission("ignis.admin")).thenReturn(true);
|
||||
var saved = new java.util.concurrent.CountDownLatch(1);
|
||||
doAnswer(call -> { saved.countDown(); return null; }).when(sender).sendMessage("Ignis change saved.");
|
||||
var command = mock(Command.class);
|
||||
when(command.getName()).thenReturn("ignisadmin");
|
||||
var dispatch = mock(EchoGameplay.class);
|
||||
doAnswer(call -> { ((Runnable) call.getArgument(0)).run(); return null; }).when(dispatch).onServer(any());
|
||||
var owner = java.util.UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
states.update(state -> ProgressAdministration.progress(state, owner, Track.DAGGER, 0, false)).get();
|
||||
var tracker = new PearlProgress(states);
|
||||
tracker.record(owner);
|
||||
var commands = new IgnisCommands(plugin, states, dispatch, null, tracker);
|
||||
commands.onCommand(sender, command, "ignisadmin", new String[]{"progress", "set", owner.toString(), "dagger", "2"});
|
||||
assertTrue(saved.await(3, java.util.concurrent.TimeUnit.SECONDS));
|
||||
tracker.checkpoint(owner).get();
|
||||
assertEquals(2, states.snapshot().players().get(owner).track(Track.DAGGER).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsLimitsProgressAndAutocompleteUseKnownOfflineUuid() throws Exception {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
@@ -33,10 +58,14 @@ class IgnisCommandsTest {
|
||||
assertTrue(states.snapshot().players().get(owner).track(Track.MASK).unlocked());
|
||||
assertEquals(100, states.snapshot().limits().invisibilityMillis());
|
||||
assertTrue(handler.onTabComplete(sender, command, "ignisadmin", new String[]{""}).contains("inspect"));
|
||||
assertEquals(java.util.List.of("echo", "mask"), handler.onTabComplete(sender, command, "ignisadmin", new String[]{"give", owner.toString(), ""}));
|
||||
assertEquals(java.util.List.of("dagger", "echo", "mask"), handler.onTabComplete(sender, command, "ignisadmin", new String[]{"give", owner.toString(), ""}));
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", owner.toString(), "mask"});
|
||||
states.update(state -> state).get();
|
||||
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.MASK));
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", owner.toString(), "dagger"});
|
||||
states.update(state -> state).get();
|
||||
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.DAGGER));
|
||||
assertTrue(states.snapshot().players().get(owner).track(Track.DAGGER).unlocked());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Statistic;
|
||||
import org.bukkit.entity.EnderPearl;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerStatisticIncrementEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PearlLaunchListenerTest {
|
||||
@Test
|
||||
void rejectsCancelledFreeUnconsumedAndUnrelatedSignals() {
|
||||
for (String scenario : java.util.List.of("cancelled", "free", "unconsumed", "wrong-stat", "wrong-item", "wrong-projectile", "expired")) {
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var item = mock(ItemStack.class);
|
||||
when(item.getType()).thenReturn(scenario.equals("wrong-item") ? Material.SNOWBALL : Material.ENDER_PEARL);
|
||||
when(item.getAmount()).thenReturn(2);
|
||||
var launch = mock(PlayerLaunchProjectileEvent.class);
|
||||
when(launch.getPlayer()).thenReturn(player);
|
||||
when(launch.getItemStack()).thenReturn(item);
|
||||
when(launch.getProjectile()).thenReturn(scenario.equals("wrong-projectile") ? mock(org.bukkit.entity.Snowball.class) : mock(EnderPearl.class));
|
||||
when(launch.shouldConsume()).thenReturn(!scenario.equals("free"));
|
||||
var used = mock(PlayerStatisticIncrementEvent.class);
|
||||
when(used.getPlayer()).thenReturn(player);
|
||||
when(used.getStatistic()).thenReturn(scenario.equals("wrong-stat") ? Statistic.DROP : Statistic.USE_ITEM);
|
||||
when(used.getMaterial()).thenReturn(Material.ENDER_PEARL);
|
||||
var credits = new ArrayList<UUID>();
|
||||
var cleanup = new ArrayList<Runnable>();
|
||||
var listener = new PearlLaunchListener(cleanup::add, credits::add);
|
||||
listener.onLaunch(launch);
|
||||
when(launch.isCancelled()).thenReturn(scenario.equals("cancelled"));
|
||||
when(item.getAmount()).thenReturn(scenario.equals("unconsumed") ? 2 : 1);
|
||||
if (scenario.equals("expired")) cleanup.forEach(Runnable::run);
|
||||
listener.onUse(used);
|
||||
assertTrue(credits.isEmpty(), scenario);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void creditsConsumedSuccessfulLaunchOnceEvenIfStatisticUpdateIsCancelled() {
|
||||
var player = mock(Player.class);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var item = mock(ItemStack.class);
|
||||
when(item.getType()).thenReturn(Material.ENDER_PEARL);
|
||||
when(item.getAmount()).thenReturn(2);
|
||||
var launch = mock(PlayerLaunchProjectileEvent.class);
|
||||
when(launch.getPlayer()).thenReturn(player);
|
||||
when(launch.getProjectile()).thenReturn(mock(EnderPearl.class));
|
||||
when(launch.getItemStack()).thenReturn(item);
|
||||
when(launch.shouldConsume()).thenReturn(true);
|
||||
var used = mock(PlayerStatisticIncrementEvent.class);
|
||||
when(used.getPlayer()).thenReturn(player);
|
||||
when(used.getStatistic()).thenReturn(Statistic.USE_ITEM);
|
||||
when(used.getMaterial()).thenReturn(Material.ENDER_PEARL);
|
||||
when(used.isCancelled()).thenReturn(true); // Pearl consumption already happened.
|
||||
var credits = new ArrayList<UUID>();
|
||||
var cleanup = new ArrayList<Runnable>();
|
||||
var listener = new PearlLaunchListener(cleanup::add, credits::add);
|
||||
listener.onLaunch(launch);
|
||||
assertTrue(credits.isEmpty(), "Launch precedes the cancellable spawn and actual consumption");
|
||||
when(item.getAmount()).thenReturn(1);
|
||||
listener.onUse(used);
|
||||
listener.onUse(used);
|
||||
assertEquals(java.util.List.of(id), credits);
|
||||
cleanup.forEach(Runnable::run);
|
||||
listener.onUse(used);
|
||||
assertEquals(1, credits.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PearlProgressTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test
|
||||
void checkpointWaitsForReservedWritesAndDrainsNewCreditsBeforeAcknowledging() throws Exception {
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var disk = new FileStateRepository(directory.resolve("state.bin"));
|
||||
StateRepository boundary = new StateRepository() {
|
||||
@Override public IgnisState load() throws java.io.IOException { return disk.load(); }
|
||||
@Override public void save(IgnisState state) throws java.io.IOException {
|
||||
entered.countDown();
|
||||
try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Timed out"); }
|
||||
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new java.io.IOException(error); }
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
try (var states = StateService.open(boundary).get()) {
|
||||
var progress = new PearlProgress(states);
|
||||
var player = UUID.randomUUID();
|
||||
progress.record(player);
|
||||
var first = progress.checkpoint(player);
|
||||
try {
|
||||
assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS));
|
||||
var duplicate = progress.checkpoint(player);
|
||||
assertFalse(duplicate.isDone(), "A reserved write is not yet durable");
|
||||
progress.record(player);
|
||||
var drain = progress.checkpoint(player);
|
||||
release.countDown();
|
||||
first.get(); duplicate.get(); drain.get();
|
||||
assertEquals(2, states.snapshot().players().get(player).track(Track.DAGGER).progress());
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void retriesFailedCreditsButResetDropsOldGenerationCredits() throws Exception {
|
||||
var disk = new FileStateRepository(directory.resolve("state.bin"));
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
StateRepository boundary = new StateRepository() {
|
||||
@Override public IgnisState load() throws java.io.IOException { return disk.load(); }
|
||||
@Override public void save(IgnisState state) throws java.io.IOException {
|
||||
if (fail.get()) throw new java.io.IOException("Simulated save failure");
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
UUID player = UUID.randomUUID();
|
||||
try (var states = StateService.open(boundary).get()) {
|
||||
var progress = new PearlProgress(states);
|
||||
progress.record(player);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> progress.checkpoint(player).get());
|
||||
fail.set(false);
|
||||
progress.checkpoint(player).get();
|
||||
assertEquals(1, states.snapshot().players().getOrDefault(player, PlayerState.empty()).track(Track.DAGGER).progress());
|
||||
progress.record(player);
|
||||
states.update(state -> ProgressAdministration.reset(state, player, java.util.Set.of(Track.DAGGER))).get();
|
||||
progress.checkpoint(player).get();
|
||||
assertEquals(0, states.snapshot().players().get(player).track(Track.DAGGER).progress());
|
||||
progress.record(player);
|
||||
progress.checkpoint(player).get();
|
||||
assertEquals(1, states.snapshot().players().get(player).track(Track.DAGGER).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumedPearlsAccumulateUnlockAtConfiguredLimitAndPersistByUuid() throws Exception {
|
||||
var disk = new FileStateRepository(directory.resolve("state.bin"));
|
||||
UUID player = UUID.randomUUID();
|
||||
try (var states = StateService.open(disk).get()) {
|
||||
states.configure(1000, 2, 1000).get();
|
||||
var progress = new PearlProgress(states);
|
||||
progress.record(player);
|
||||
progress.checkpointAll().get();
|
||||
assertEquals(1, states.snapshot().players().get(player).track(Track.DAGGER).progress());
|
||||
assertFalse(states.snapshot().players().get(player).track(Track.DAGGER).unlocked());
|
||||
progress.record(player);
|
||||
progress.checkpoint(player).get();
|
||||
assertTrue(states.snapshot().players().get(player).track(Track.DAGGER).unlocked());
|
||||
}
|
||||
try (var states = StateService.open(disk).get()) {
|
||||
assertEquals(2, states.snapshot().players().get(player).track(Track.DAGGER).progress());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RecipeDeliveryRetryTest {
|
||||
@TempDir Path directory;
|
||||
@Test void daggerGiftAcknowledgementRetryDoesNotOfferAnotherPhysicalCopy() throws Exception { verifyRetry(false); }
|
||||
@Test void maskGiftAcknowledgementRetryDoesNotOfferAnotherPhysicalCopy() throws Exception { verifyRetry(true); }
|
||||
|
||||
private void verifyRetry(boolean mask) throws Exception {
|
||||
var track = mask ? Track.MASK : Track.DAGGER;
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var failAcknowledgement = new AtomicBoolean(true);
|
||||
var disk = new FileStateRepository(directory.resolve(track + ".bin"));
|
||||
StateRepository boundary = new StateRepository() {
|
||||
@Override public IgnisState load() throws IOException { return disk.load(); }
|
||||
@Override public void save(IgnisState state) throws IOException {
|
||||
if (failAcknowledgement.get() && !state.players().get(owner).pendingRewards().containsKey(receipt)) {
|
||||
throw new IOException("Simulated gift acknowledgement failure");
|
||||
}
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(server.addRecipe(recipe)).thenReturn(true);
|
||||
var maskItems = mock(MaskItems.class);
|
||||
when(maskItems.ready()).thenReturn(true);
|
||||
var maskToken = new MaskToken(owner, receipt, 0);
|
||||
when(maskItems.offer(player, maskToken)).thenReturn(true, false);
|
||||
var daggerItems = mock(DaggerItems.class);
|
||||
var daggerToken = new ItemToken(owner, receipt, 0);
|
||||
when(daggerItems.offer(player, daggerToken)).thenReturn(true, false);
|
||||
var dispatch = mock(EchoGameplay.class);
|
||||
doAnswer(call -> { ((Runnable) call.getArgument(0)).run(); return null; }).when(dispatch).onServer(any());
|
||||
try (var states = StateService.open(boundary).get()) {
|
||||
states.update(state -> new TradableRewards(track).issue(state, owner, receipt, true)).get();
|
||||
Runnable crafting = mask ? new MaskCrafting(plugin, states, maskItems, dispatch, ignored -> recipe)
|
||||
: new DaggerCrafting(plugin, states, daggerItems, dispatch, ignored -> recipe);
|
||||
crafting.run();
|
||||
states.update(state -> state).get();
|
||||
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsKey(receipt));
|
||||
// The recipient can trade the gift away while the acknowledgement save is failing.
|
||||
failAcknowledgement.set(false);
|
||||
crafting.run();
|
||||
states.update(state -> state).get();
|
||||
assertFalse(states.snapshot().players().get(owner).pendingRewards().containsKey(receipt));
|
||||
if (mask) verify(maskItems, times(1)).offer(player, maskToken);
|
||||
else verify(daggerItems, times(1)).offer(player, daggerToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TradableRewardsTest {
|
||||
@Test
|
||||
void daggerRequiresBothUnlocksAndMatchingTrackReceiptAndResetGeneration() {
|
||||
var rewards = new TradableRewards(Track.DAGGER);
|
||||
UUID issuer = UUID.randomUUID(), holder = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var token = new ItemToken(issuer, receipt, 0);
|
||||
var empty = IgnisState.empty();
|
||||
assertThrows(IllegalArgumentException.class, () -> rewards.issue(empty, issuer, receipt, false));
|
||||
var issued = rewards.issue(empty, issuer, receipt, true);
|
||||
assertTrue(rewards.valid(issued, issuer, token));
|
||||
assertFalse(rewards.valid(issued, holder, token));
|
||||
var holderUnlocked = ProgressAdministration.progress(issued, holder, Track.DAGGER, 313, false);
|
||||
assertTrue(rewards.valid(holderUnlocked, holder, token));
|
||||
assertFalse(new TradableRewards(Track.MASK).valid(holderUnlocked, holder, token));
|
||||
var reset = ProgressAdministration.reset(holderUnlocked, issuer, Set.of(Track.DAGGER));
|
||||
var earnedAgain = ProgressAdministration.progress(reset, issuer, Track.DAGGER, 313, false);
|
||||
assertFalse(rewards.valid(earnedAgain, holder, token));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user