Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
183b6660b2 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Spigot Ignis
|
||||
|
||||
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss and advancement-name scrambling are implemented in source; live-server verification is pending.** The Mask, Dagger, and Heart gameplay are not implemented yet.
|
||||
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.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -11,7 +11,7 @@ Requires Java 17+ to launch Gradle; the wrapper resolves a Java 25 toolchain for
|
||||
./gradlew clean jar -PreleaseVersion=0.1.0
|
||||
```
|
||||
|
||||
Targets Purpur `26.2.build.2618-stable`. The API is not bundled. Source builds do not require the private wiki.
|
||||
Targets Purpur `26.2.build.2618-stable` and requires **ProtocolLib 5.4.0** for Mask helmet projection. Neither server API nor ProtocolLib is bundled. Source builds do not require the private wiki.
|
||||
|
||||
## Knowledge and delivery
|
||||
|
||||
@@ -37,11 +37,25 @@ The earned Echo Shard is named **Echo of the Abyss**, with cryptic lore and a UU
|
||||
|
||||
Full inventories retain a pending reward; online delivery retries every five seconds using the same receipt identity. Temporary inventory/drop/swap locks prevent ordinary moves during the short activation commit. Disk failures do not consume the item on activation; failed completion saves produce a player-facing recovery message rather than pretending the reward was saved.
|
||||
|
||||
Announcement handling uses Purpur's Adventure `PlayerAdvancementDoneEvent.message`, replacing only the first name argument of the three vanilla advancement translations. It preserves silent events and unrelated/custom message formats. No ProtocolLib dependency is introduced. Existing Stealth outbound masking sees the obfuscated alias rather than the original name. Live coexistence remains unverified.
|
||||
Announcement handling uses Purpur's Adventure `PlayerAdvancementDoneEvent.message`, replacing only the first name argument of the three vanilla advancement translations. It preserves silent events and unrelated/custom message formats. Advancement transformation itself needs no ProtocolLib interception; the plugin's ProtocolLib dependency serves Mask equipment projection. Existing Stealth outbound masking sees the obfuscated alias rather than the original name. Live coexistence remains unverified.
|
||||
|
||||
## The Mask
|
||||
|
||||
Only actual invisibility effects from directly drunk potions count toward the default **12-hour** threshold. Splash, lingering, arrows, commands, other plugins, and the Mask itself do not count. Cancelled or non-overriding effect changes do not change attribution. Time uses a monotonic clock; refresh/overlap does not double-count. Whole milliseconds persist with sub-millisecond credit retained in memory. Failed saves retain reserved credit for retry rather than silently discarding it.
|
||||
|
||||
Checkpoints run every five seconds and on effect end, disconnect, and disable. Offline time never counts. A plugin-owned player-data provenance marker permits an existing directly drunk effect to resume online counting after reconnect/restart; marker restoration and interactions with plugins changing effects while Ignis is disabled still require live validation. A reset discards old-generation buffered credit; future qualifying time can accumulate anew. Administrator time edits flush current credit before changing the counter.
|
||||
|
||||
Unlocked players can craft **The Mask** from eight Netherite Blocks surrounding a Nether Star. The player-head skin is `IgnisUmbra10`, resolved using the server's asynchronous profile API (20-second timeout, retry every five minutes until ready). There is no blocking profile lookup on the tick thread. Missing/incomplete skins withhold crafting and queued item creation, not ingredients. Previously issued Masks can still be identified/used from their embedded profile without a new lookup.
|
||||
|
||||
Crafting requires a durable issuer receipt **before** an authenticated result becomes available. Vanilla crafting handles ingredient consumption, including shift-crafting; the plugin does not manually escrow/delete those expensive ingredients while waiting for disk. Locked/invalid outputs and automated Crafters are rejected. Recipe-book discovery follows personal unlocks; clients/mods may inspect global recipe definitions, but cannot bypass server crafting permission checks.
|
||||
|
||||
A crafter's generation can share a recipe receipt across multiple legitimately paid crafts. Masks are tradable, but a holder must have their own unlock. Validity also requires the issuer's matching receipt/generation: resetting the issuer invalidates their issued copies even when traded away. Resetting a recipient removes that recipient's unlock and invalidates items issued by them; another issuer's still-valid Mask may be used again once the recipient re-earns their own unlock.
|
||||
|
||||
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.
|
||||
|
||||
## Commands
|
||||
|
||||
All players may use `/ignis umbra` to privately see the three item tracks. Their natural counters and items are not active yet; 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. 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.
|
||||
|
||||
Administration requires **`ignis.admin`** (default: operators), checked independently during execution and autocomplete:
|
||||
|
||||
@@ -51,12 +65,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
|
||||
/ignisadmin give <online-name|known-UUID> <echo|mask>
|
||||
/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 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 grants unlock use and queue the authenticated item for delivery after profile resolution/inventory availability. 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.
|
||||
|
||||
@@ -74,4 +88,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 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; and admin permission/confirmation/offline UUID cases.
|
||||
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.
|
||||
@@ -15,6 +15,8 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Server-thread ownership of this plugin's effective invisibility contribution. */
|
||||
public final class EffectOwnership {
|
||||
private final Set<UUID> applying = new HashSet<>();
|
||||
private final Set<UUID> owned = new HashSet<>();
|
||||
|
||||
public void applying(UUID player, boolean active) {
|
||||
if (active) applying.add(player); else applying.remove(player);
|
||||
}
|
||||
public void changed(UUID player, boolean actuallyChanged, boolean remainsPresent) {
|
||||
if (!actuallyChanged) return;
|
||||
if (remainsPresent && applying.contains(player)) owned.add(player);
|
||||
else owned.remove(player);
|
||||
}
|
||||
public boolean owns(UUID player) { return owned.contains(player); }
|
||||
public void forget(UUID player) { applying.remove(player); owned.remove(player); }
|
||||
}
|
||||
@@ -18,12 +18,18 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final EchoGameplay gameplay;
|
||||
private final InvisibilityProgress maskTracker;
|
||||
private final Map<CommandSender, Confirmation> confirmations = new HashMap<>();
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay) {
|
||||
this(plugin, states, gameplay, null);
|
||||
}
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay, InvisibilityProgress maskTracker) {
|
||||
this.plugin = plugin;
|
||||
this.states = states;
|
||||
this.gameplay = gameplay;
|
||||
this.maskTracker = maskTracker;
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
@@ -69,8 +75,13 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
Track track = Track.valueOf(args[3].toUpperCase(Locale.ROOT));
|
||||
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());
|
||||
mutation(sender, "progress " + id + " " + track + " " + args[1] + " " + value,
|
||||
states.update(state -> ProgressAdministration.progress(state, id, track, value, add)));
|
||||
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")) {
|
||||
UUID id = resolve(args[1]), receipt = UUID.randomUUID();
|
||||
mutation(sender, "grant Mask " + id, states.update(state -> MaskRewards.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();
|
||||
@@ -95,7 +106,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; reset <player|UUID> <track|all> [confirm].");
|
||||
+ "give <player|UUID> <echo|mask>; reset <player|UUID> <track|all> [confirm].");
|
||||
} catch (IllegalArgumentException | ArithmeticException exception) {
|
||||
sender.sendMessage("Invalid Ignis command: " + exception.getMessage());
|
||||
}
|
||||
@@ -154,7 +165,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");
|
||||
} else if (args.length == 3 && sub.equals("give")) options = List.of("echo", "mask");
|
||||
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,12 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
/** Attributes only actual, uncancelled invisibility changes to their source. */
|
||||
public final class InvisibilityAttribution {
|
||||
public enum Transition { KEEP, START, STOP }
|
||||
private InvisibilityAttribution() { }
|
||||
public static Transition transition(boolean invisibility, boolean cancelled, boolean changed,
|
||||
boolean remainsPresent, boolean directlyDrank) {
|
||||
if (!invisibility || cancelled || !changed) return Transition.KEEP;
|
||||
return remainsPresent && directlyDrank ? Transition.START : Transition.STOP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Monotonic qualifying intervals. No wall-clock/offline time and no Bukkit access. */
|
||||
public final class InvisibilityProgress {
|
||||
private static final class Session {
|
||||
private long generation;
|
||||
private long lastNanos;
|
||||
private long pendingNanos;
|
||||
private boolean active;
|
||||
private CompletableFuture<IgnisState> pendingSave;
|
||||
private Session(long generation, long now) { this.generation = generation; lastNanos = now; }
|
||||
}
|
||||
private final StateService states;
|
||||
private final LongSupplier nanoTime;
|
||||
private final Map<UUID, Session> sessions = new HashMap<>();
|
||||
|
||||
public InvisibilityProgress(StateService states, LongSupplier nanoTime) {
|
||||
this.states = Objects.requireNonNull(states);
|
||||
this.nanoTime = Objects.requireNonNull(nanoTime);
|
||||
}
|
||||
|
||||
public synchronized void drink(UUID player) {
|
||||
Session session = accrue(player);
|
||||
session.active = true;
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<IgnisState> stop(UUID player) {
|
||||
Session session = accrue(player);
|
||||
session.active = false;
|
||||
return checkpoint(player);
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<IgnisState> checkpoint(UUID player) {
|
||||
Session session = accrue(player);
|
||||
long milliseconds = session.pendingNanos / 1_000_000L;
|
||||
if (milliseconds == 0) return session.pendingSave != null ? session.pendingSave
|
||||
: CompletableFuture.completedFuture(states.snapshot());
|
||||
long reservedNanos = milliseconds * 1_000_000L;
|
||||
long generation = session.generation;
|
||||
session.pendingNanos -= reservedNanos;
|
||||
var result = states.update(state -> {
|
||||
long currentGeneration = state.players().getOrDefault(player, PlayerState.empty()).track(Track.MASK).generation();
|
||||
return currentGeneration == generation
|
||||
? ProgressAdministration.progress(state, player, Track.MASK, milliseconds, true) : state;
|
||||
});
|
||||
var acknowledgement = new CompletableFuture<IgnisState>();
|
||||
session.pendingSave = acknowledgement;
|
||||
result.whenComplete((saved, error) -> {
|
||||
synchronized (this) {
|
||||
if (error != null && session.generation == generation) {
|
||||
session.pendingNanos = Math.addExact(session.pendingNanos, reservedNanos);
|
||||
}
|
||||
if (session.pendingSave == acknowledgement) session.pendingSave = null;
|
||||
}
|
||||
if (error != null) acknowledgement.completeExceptionally(error);
|
||||
else acknowledgement.complete(saved);
|
||||
});
|
||||
return acknowledgement;
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<Void> checkpointAll() {
|
||||
return CompletableFuture.allOf(java.util.List.copyOf(sessions.keySet()).stream()
|
||||
.map(this::checkpoint).toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<Void> stopAll() {
|
||||
return CompletableFuture.allOf(java.util.List.copyOf(sessions.keySet()).stream()
|
||||
.map(this::stop).toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
private Session accrue(UUID player) {
|
||||
Objects.requireNonNull(player);
|
||||
long now = nanoTime.getAsLong();
|
||||
long generation = states.snapshot().players().getOrDefault(player, PlayerState.empty()).track(Track.MASK).generation();
|
||||
Session session = sessions.computeIfAbsent(player, ignored -> new Session(generation, now));
|
||||
if (generation != session.generation) {
|
||||
session.generation = generation;
|
||||
session.pendingNanos = 0;
|
||||
} else if (session.active) {
|
||||
session.pendingNanos = Math.addExact(session.pendingNanos, Math.max(0, now - session.lastNanos));
|
||||
}
|
||||
session.lastNanos = now;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Receipts precede crafting; vanilla owns all ingredient consumption, including shift-crafting. */
|
||||
public final class MaskCrafting implements Listener, Runnable {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("spigotignis", "mask");
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final MaskItems items;
|
||||
private final EchoGameplay dispatch;
|
||||
private final Set<UUID> preparing = new HashSet<>();
|
||||
private final Set<MaskToken> delivering = new HashSet<>();
|
||||
private boolean registered;
|
||||
private final java.util.function.Function<org.bukkit.inventory.ItemStack, ShapedRecipe> recipeFactory;
|
||||
|
||||
public MaskCrafting(JavaPlugin plugin, StateService states, MaskItems items, EchoGameplay dispatch) {
|
||||
this(plugin, states, items, dispatch, result -> new ShapedRecipe(RECIPE, result));
|
||||
}
|
||||
|
||||
MaskCrafting(JavaPlugin plugin, StateService states, MaskItems items, EchoGameplay dispatch,
|
||||
java.util.function.Function<org.bukkit.inventory.ItemStack, ShapedRecipe> recipeFactory) {
|
||||
this.plugin = plugin; this.states = states; this.items = items; this.dispatch = dispatch;
|
||||
this.recipeFactory = recipeFactory;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (!isMask(event.getRecipe())) return;
|
||||
var identity = items.identify(event.getInventory().getResult());
|
||||
if (!(event.getWhoClicked() instanceof Player player) || identity.isEmpty()
|
||||
|| !identity.orElseThrow().issuer().equals(player.getUniqueId())
|
||||
|| !MaskRewards.valid(states.snapshot(), player.getUniqueId(), identity.orElseThrow())) {
|
||||
event.setCancelled(true);
|
||||
event.getInventory().setResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCrafter(CrafterCraftEvent event) {
|
||||
if (isMask(event.getRecipe())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onPrepare(PrepareItemCraftEvent event) {
|
||||
if (!isMask(event.getInventory().getRecipe()) || event.getInventory().getResult() == null) return;
|
||||
if (!(event.getView().getPlayer() instanceof Player player) || !items.ready()) {
|
||||
event.getInventory().setResult(null);
|
||||
return;
|
||||
}
|
||||
var token = MaskRewards.craftingToken(states.snapshot(), player.getUniqueId());
|
||||
event.getInventory().setResult(token.map(items::create).orElse(null));
|
||||
if (token.isEmpty()) ensureReceipt(player);
|
||||
}
|
||||
|
||||
@Override public void run() {
|
||||
if (!items.ready()) return;
|
||||
if (!registered) {
|
||||
var recipe = recipeFactory.apply(items.prototype());
|
||||
recipe.shape("NNN", "NSN", "NNN");
|
||||
recipe.setIngredient('N', Material.NETHERITE_BLOCK);
|
||||
recipe.setIngredient('S', Material.NETHER_STAR);
|
||||
registered = plugin.getServer().addRecipe(recipe);
|
||||
if (!registered) return;
|
||||
}
|
||||
for (Player player : plugin.getServer().getOnlinePlayers()) {
|
||||
var state = states.snapshot().players().getOrDefault(player.getUniqueId(), PlayerState.empty());
|
||||
if (!state.track(Track.MASK).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.MASK) continue;
|
||||
var token = new MaskToken(player.getUniqueId(), entry.getKey(), state.track(Track.MASK).generation());
|
||||
if (delivering.contains(token) || !items.offer(player, token)) continue;
|
||||
delivering.add(token);
|
||||
states.update(current -> MaskRewards.acknowledge(current, token))
|
||||
.whenComplete((ignored, error) -> dispatch.onServer(() -> delivering.remove(token)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureReceipt(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
var snapshot = states.snapshot();
|
||||
var state = snapshot.players().getOrDefault(owner, PlayerState.empty()).track(Track.MASK);
|
||||
if (!state.unlocked() || MaskRewards.craftingToken(snapshot, owner).isPresent() || !preparing.add(owner)) return;
|
||||
UUID receipt = UUID.randomUUID();
|
||||
long generation = state.generation();
|
||||
states.update(current -> {
|
||||
var now = current.players().getOrDefault(owner, PlayerState.empty()).track(Track.MASK);
|
||||
return now.unlocked() && now.generation() == generation
|
||||
? MaskRewards.issue(current, owner, receipt, false) : current;
|
||||
}).whenComplete((ignored, error) -> dispatch.onServer(() -> {
|
||||
preparing.remove(owner);
|
||||
Player online = plugin.getServer().getPlayer(owner);
|
||||
if (error != null || online == null || !items.ready()) return;
|
||||
var view = online.getOpenInventory();
|
||||
if (view.getTopInventory() instanceof CraftingInventory inventory && isMask(inventory.getRecipe())) {
|
||||
// Re-run preparation so other plugins retain their chance to deny/adjust the result.
|
||||
inventory.setResult(items.prototype());
|
||||
plugin.getServer().getPluginManager().callEvent(new PrepareItemCraftEvent(inventory, view, false));
|
||||
online.updateInventory();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (registered) plugin.getServer().removeRecipe(RECIPE);
|
||||
}
|
||||
|
||||
private static boolean isMask(Recipe recipe) {
|
||||
return recipe instanceof Keyed keyed && keyed.getKey().equals(RECIPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Server-thread effect ownership and client-only helmet projection. */
|
||||
public final class MaskEffects implements Listener, Runnable {
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final MaskItems items;
|
||||
private final EffectOwnership ownership = new EffectOwnership();
|
||||
private final AtomicReference<Set<Integer>> hidden = new AtomicReference<>(Set.of());
|
||||
|
||||
public MaskEffects(JavaPlugin plugin, StateService states, MaskItems items) {
|
||||
this.plugin = plugin; this.states = states; this.items = items;
|
||||
}
|
||||
public Set<Integer> hiddenEntityIds() { return hidden.get(); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onEffect(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() instanceof Player player && event.getModifiedType().equals(PotionEffectType.INVISIBILITY)) {
|
||||
ownership.changed(player.getUniqueId(), event.getAction() != EntityPotionEffectEvent.Action.CHANGED || event.isOverride(),
|
||||
event.getNewEffect() != null);
|
||||
}
|
||||
}
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) {
|
||||
removeOwn(event.getPlayer());
|
||||
ownership.forget(event.getPlayer().getUniqueId());
|
||||
var next = new HashSet<>(hidden.get());
|
||||
next.remove(event.getPlayer().getEntityId());
|
||||
hidden.set(Set.copyOf(next));
|
||||
}
|
||||
|
||||
@Override public void run() {
|
||||
Set<Integer> before = hidden.get();
|
||||
Set<Integer> next = new HashSet<>();
|
||||
for (Player player : plugin.getServer().getOnlinePlayers()) {
|
||||
var token = items.identify(player.getInventory().getHelmet());
|
||||
boolean eligible = !player.isDead() && token.filter(value -> MaskRewards.valid(states.snapshot(), player.getUniqueId(), value)).isPresent();
|
||||
if (eligible) {
|
||||
PotionEffect current = player.getPotionEffect(PotionEffectType.INVISIBILITY);
|
||||
if (current == null || ownership.owns(player.getUniqueId()) && current.getDuration() <= 20) {
|
||||
ownership.applying(player.getUniqueId(), true);
|
||||
try { player.addPotionEffect(new PotionEffect(PotionEffectType.INVISIBILITY, 40, 0, true, false, false)); }
|
||||
finally { ownership.applying(player.getUniqueId(), false); }
|
||||
}
|
||||
if (player.hasPotionEffect(PotionEffectType.INVISIBILITY)) next.add(player.getEntityId());
|
||||
} else removeOwn(player);
|
||||
}
|
||||
hidden.set(Set.copyOf(next));
|
||||
for (Player player : plugin.getServer().getOnlinePlayers()) {
|
||||
if (before.contains(player.getEntityId()) != next.contains(player.getEntityId())) refreshEquipment(player);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeOwn(Player player) {
|
||||
if (ownership.owns(player.getUniqueId())) {
|
||||
player.removePotionEffect(PotionEffectType.INVISIBILITY);
|
||||
ownership.forget(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshEquipment(Player player) {
|
||||
ItemStack helmet = player.getInventory().getHelmet();
|
||||
if (helmet == null) helmet = new ItemStack(Material.AIR);
|
||||
for (Player observer : player.getTrackedBy()) observer.sendEquipmentChange(player, EquipmentSlot.HEAD, helmet);
|
||||
player.sendEquipmentChange(player, EquipmentSlot.HEAD, helmet);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
Set<Integer> previous = hidden.getAndSet(Set.of());
|
||||
for (Player player : plugin.getServer().getOnlinePlayers()) {
|
||||
removeOwn(player);
|
||||
if (previous.contains(player.getEntityId())) refreshEquipment(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import com.comphenix.protocol.events.PacketAdapter;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.wrappers.EnumWrappers;
|
||||
import com.comphenix.protocol.wrappers.Pair;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Packet thread reads an immutable entity-ID snapshot, never mutable Bukkit state. */
|
||||
public final class MaskEquipmentListener extends PacketAdapter {
|
||||
private final Supplier<Set<Integer>> wearers;
|
||||
private final AtomicBoolean warned = new AtomicBoolean();
|
||||
private final java.util.function.Consumer<String> warning;
|
||||
|
||||
public MaskEquipmentListener(Plugin plugin, Supplier<Set<Integer>> wearers, java.util.function.Consumer<String> warning) {
|
||||
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.ENTITY_EQUIPMENT);
|
||||
this.wearers = wearers;
|
||||
this.warning = warning;
|
||||
}
|
||||
|
||||
public static java.util.List<Pair<EnumWrappers.ItemSlot, ItemStack>> project(
|
||||
java.util.List<Pair<EnumWrappers.ItemSlot, ItemStack>> equipment, ItemStack empty) {
|
||||
var projected = new ArrayList<Pair<EnumWrappers.ItemSlot, ItemStack>>();
|
||||
for (var pair : equipment) {
|
||||
projected.add(pair.getFirst() == EnumWrappers.ItemSlot.HEAD ? new Pair<>(pair.getFirst(), empty) : pair);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
@Override public void onPacketSending(PacketEvent event) {
|
||||
if (event.isCancelled()) return;
|
||||
try {
|
||||
if (!wearers.get().contains(event.getPacket().getIntegers().read(0))) return;
|
||||
var packet = event.getPacket().shallowClone();
|
||||
packet.getSlotStackPairLists().write(0, project(packet.getSlotStackPairLists().read(0), new ItemStack(Material.AIR)));
|
||||
event.setPacket(packet);
|
||||
} catch (RuntimeException exception) {
|
||||
if (warned.compareAndSet(false, true)) {
|
||||
warning.accept("Mask helmet projection unavailable; original equipment packet retained. Verify ProtocolLib/server compatibility.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Optional;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.profile.PlayerProfile;
|
||||
|
||||
public final class MaskItems {
|
||||
private static final NamespacedKey IDENTITY = new NamespacedKey("spigotignis", "mask_identity");
|
||||
private PlayerProfile profile;
|
||||
|
||||
public boolean ready() { return profile != null; }
|
||||
public void profile(PlayerProfile resolved) {
|
||||
if (resolved.getUniqueId() == null || resolved.getTextures().getSkin() == null) {
|
||||
throw new IllegalArgumentException("The Mask skin profile is incomplete.");
|
||||
}
|
||||
profile = resolved.clone();
|
||||
}
|
||||
|
||||
public ItemStack prototype() {
|
||||
if (!ready()) throw new IllegalStateException("The Mask skin is unavailable.");
|
||||
var item = new ItemStack(Material.PLAYER_HEAD);
|
||||
var meta = (SkullMeta) item.getItemMeta();
|
||||
meta.displayName(Component.text("The Mask", NamedTextColor.DARK_PURPLE));
|
||||
meta.setOwnerProfile(profile);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
public ItemStack create(MaskToken token) {
|
||||
var item = prototype();
|
||||
var meta = item.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(IDENTITY, PersistentDataType.STRING,
|
||||
EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
public Optional<MaskToken> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.PLAYER_HEAD || !item.hasItemMeta()) return Optional.empty();
|
||||
return EchoIdentity.decode(item.getItemMeta().getPersistentDataContainer().get(IDENTITY, PersistentDataType.STRING))
|
||||
.map(token -> new MaskToken(token.owner(), token.receipt(), token.generation()));
|
||||
}
|
||||
|
||||
public boolean offer(Player player, MaskToken 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Provenance marker survives offline time; online elapsed time is tracked separately. */
|
||||
public final class MaskProgressListener implements Listener {
|
||||
private static final NamespacedKey DRANK = new NamespacedKey("spigotignis", "qualifying_invisibility");
|
||||
private final JavaPlugin plugin;
|
||||
private final InvisibilityProgress tracker;
|
||||
private final AtomicBoolean warned = new AtomicBoolean();
|
||||
|
||||
public MaskProgressListener(JavaPlugin plugin, InvisibilityProgress tracker) {
|
||||
this.plugin = plugin;
|
||||
this.tracker = tracker;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onEffect(EntityPotionEffectEvent event) {
|
||||
if (!(event.getEntity() instanceof Player player)) return;
|
||||
boolean changed = event.getAction() != EntityPotionEffectEvent.Action.CHANGED || event.isOverride();
|
||||
var transition = InvisibilityAttribution.transition(
|
||||
event.getModifiedType().equals(PotionEffectType.INVISIBILITY), event.isCancelled(), changed,
|
||||
event.getNewEffect() != null, event.getCause() == EntityPotionEffectEvent.Cause.POTION_DRINK);
|
||||
switch (transition) {
|
||||
case START -> {
|
||||
player.getPersistentDataContainer().set(DRANK, PersistentDataType.BYTE, (byte) 1);
|
||||
tracker.drink(player.getUniqueId());
|
||||
}
|
||||
case STOP -> {
|
||||
player.getPersistentDataContainer().remove(DRANK);
|
||||
observe(tracker.stop(player.getUniqueId()));
|
||||
}
|
||||
case KEEP -> { }
|
||||
}
|
||||
}
|
||||
|
||||
public void resume(Player player) {
|
||||
if (!player.isOnline()) return;
|
||||
if (!player.isDead() && player.hasPotionEffect(PotionEffectType.INVISIBILITY)
|
||||
&& Byte.valueOf((byte) 1).equals(player.getPersistentDataContainer().get(DRANK, PersistentDataType.BYTE))) {
|
||||
tracker.drink(player.getUniqueId());
|
||||
} else player.getPersistentDataContainer().remove(DRANK);
|
||||
}
|
||||
|
||||
@EventHandler public void onJoin(PlayerJoinEvent event) {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> resume(event.getPlayer()));
|
||||
}
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) { observe(tracker.stop(event.getPlayer().getUniqueId())); }
|
||||
@EventHandler public void onDeath(PlayerDeathEvent event) {
|
||||
event.getEntity().getPersistentDataContainer().remove(DRANK);
|
||||
observe(tracker.stop(event.getEntity().getUniqueId()));
|
||||
}
|
||||
|
||||
public void checkpoint() { observe(tracker.checkpointAll()); }
|
||||
public void stop() { observe(tracker.stopAll()); }
|
||||
|
||||
private void observe(CompletableFuture<?> result) {
|
||||
result.whenComplete((ignored, error) -> {
|
||||
if (error == null) warned.set(false);
|
||||
else if (warned.compareAndSet(false, true)) {
|
||||
plugin.getLogger().warning("Mask progress save failed; elapsed credit is retained in memory for retry. Check storage before stopping the server.");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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. */
|
||||
public final class MaskRewards {
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Fixed, approved skin name; profile resolution uses the server's asynchronous API. */
|
||||
public final class MaskSkinLoader implements Runnable {
|
||||
private final JavaPlugin plugin;
|
||||
private final MaskItems items;
|
||||
private final EchoGameplay dispatch;
|
||||
private boolean fetching;
|
||||
|
||||
public MaskSkinLoader(JavaPlugin plugin, MaskItems items, EchoGameplay dispatch) {
|
||||
this.plugin = plugin; this.items = items; this.dispatch = dispatch;
|
||||
}
|
||||
|
||||
@Override public void run() {
|
||||
if (items.ready() || fetching) return;
|
||||
fetching = true;
|
||||
try {
|
||||
plugin.getServer().createPlayerProfile("IgnisUmbra10").update().orTimeout(20, TimeUnit.SECONDS)
|
||||
.whenComplete((profile, error) -> dispatch.onServer(() -> {
|
||||
fetching = false;
|
||||
if (error == null) {
|
||||
try { items.profile(profile); return; }
|
||||
catch (IllegalArgumentException ignored) { /* No default/wrong skin reward. */ }
|
||||
}
|
||||
plugin.getLogger().warning("Mask skin resolution unavailable; crafting and pending item delivery wait for retry.");
|
||||
}));
|
||||
} catch (RuntimeException exception) {
|
||||
fetching = false;
|
||||
plugin.getLogger().warning("Mask profile request could not start; crafting waits for retry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public record MaskToken(UUID issuer, UUID receipt, long generation) {
|
||||
public MaskToken {
|
||||
Objects.requireNonNull(issuer);
|
||||
Objects.requireNonNull(receipt);
|
||||
if (generation < 0) throw new IllegalArgumentException("Invalid Mask generation.");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import org.bukkit.plugin.java.JavaPlugin;
|
||||
/** Registers gameplay only after durable state initialization succeeds. */
|
||||
public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
private CompletableFuture<StateService> initialization;
|
||||
private MaskProgressListener maskProgress;
|
||||
private MaskCrafting maskCrafting;
|
||||
private MaskEffects maskEffects;
|
||||
private MaskEquipmentListener maskEquipment;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -22,13 +26,28 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(gameplay, this);
|
||||
getServer().getPluginManager().registerEvents(new AnnouncementListener(gameplay::activated), this);
|
||||
getServer().getScheduler().runTaskTimer(this, gameplay::retryDeliveries, 1L, 100L);
|
||||
var commands = new IgnisCommands(this, service, gameplay);
|
||||
var maskTracker = new InvisibilityProgress(service, System::nanoTime);
|
||||
maskProgress = new MaskProgressListener(this, maskTracker);
|
||||
getServer().getPluginManager().registerEvents(maskProgress, this);
|
||||
getServer().getOnlinePlayers().forEach(maskProgress::resume);
|
||||
getServer().getScheduler().runTaskTimer(this, maskProgress::checkpoint, 1L, 100L);
|
||||
var maskItems = new MaskItems();
|
||||
maskCrafting = new MaskCrafting(this, service, maskItems, gameplay);
|
||||
maskEffects = new MaskEffects(this, service, maskItems);
|
||||
maskEquipment = new MaskEquipmentListener(this, maskEffects::hiddenEntityIds, getLogger()::warning);
|
||||
com.comphenix.protocol.ProtocolLibrary.getProtocolManager().addPacketListener(maskEquipment);
|
||||
getServer().getPluginManager().registerEvents(maskCrafting, this);
|
||||
getServer().getPluginManager().registerEvents(maskEffects, this);
|
||||
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);
|
||||
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 challenge enabled. Further abilities are still in development.");
|
||||
getLogger().info("Ignis Echo and Mask enabled. Dagger and Heart remain in development.");
|
||||
});
|
||||
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
|
||||
service.close();
|
||||
@@ -39,6 +58,10 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@ name: SpigotIgnis
|
||||
version: '${version}'
|
||||
main: games.dmg.spigotignis.SpigotIgnisPlugin
|
||||
api-version: '1.21'
|
||||
description: Earnable shadow progression with the Echo of the Abyss.
|
||||
description: Earnable shadow progression with Echo of the Abyss and The Mask.
|
||||
depend: [ProtocolLib]
|
||||
commands:
|
||||
ignis:
|
||||
description: Inspect private shadow progression.
|
||||
usage: /ignis umbra
|
||||
ignisadmin:
|
||||
description: Administer Ignis progression and Echo rewards.
|
||||
description: Administer Ignis progression and unlocked rewards.
|
||||
usage: /ignisadmin
|
||||
permission: ignis.admin
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EffectOwnershipTest {
|
||||
@Test
|
||||
void onlyAnActuallyAppliedOwnEffectCanBeRemovedAndExternalReplacementRevokesOwnership() {
|
||||
UUID player = UUID.randomUUID();
|
||||
var ownership = new EffectOwnership();
|
||||
ownership.applying(player, true);
|
||||
ownership.changed(player, false, true);
|
||||
assertFalse(ownership.owns(player));
|
||||
ownership.changed(player, true, true);
|
||||
ownership.applying(player, false);
|
||||
assertTrue(ownership.owns(player));
|
||||
ownership.changed(player, false, true);
|
||||
assertTrue(ownership.owns(player));
|
||||
ownership.changed(player, true, true);
|
||||
assertFalse(ownership.owns(player));
|
||||
ownership.applying(player, true);
|
||||
ownership.changed(player, true, true);
|
||||
ownership.applying(player, false);
|
||||
ownership.changed(player, true, false);
|
||||
assertFalse(ownership.owns(player));
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,10 @@ 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"), handler.onTabComplete(sender, command, "ignisadmin", new String[]{"give", owner.toString(), ""}));
|
||||
assertEquals(java.util.List.of("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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class InvisibilityAttributionTest {
|
||||
@Test
|
||||
void directDrinkStartsAndOtherSourcesOrRemovalStopButCancelledOrIgnoredChangesDoNothing() {
|
||||
assertEquals(InvisibilityAttribution.Transition.START,
|
||||
InvisibilityAttribution.transition(true, false, true, true, true));
|
||||
assertEquals(InvisibilityAttribution.Transition.STOP,
|
||||
InvisibilityAttribution.transition(true, false, true, true, false));
|
||||
assertEquals(InvisibilityAttribution.Transition.STOP,
|
||||
InvisibilityAttribution.transition(true, false, true, false, true));
|
||||
assertEquals(InvisibilityAttribution.Transition.KEEP,
|
||||
InvisibilityAttribution.transition(true, true, true, true, true));
|
||||
assertEquals(InvisibilityAttribution.Transition.KEEP,
|
||||
InvisibilityAttribution.transition(false, false, true, true, true));
|
||||
assertEquals(InvisibilityAttribution.Transition.KEEP,
|
||||
InvisibilityAttribution.transition(true, false, false, true, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class InvisibilityProgressTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test
|
||||
void resetDropsOldUncheckpointedTimeAndOnlyFutureTimeCanBeEarned() throws Exception {
|
||||
UUID player = UUID.randomUUID();
|
||||
var clock = new AtomicLong();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
var tracker = new InvisibilityProgress(states, clock::get);
|
||||
tracker.drink(player);
|
||||
clock.set(5_000_000_000L);
|
||||
states.update(state -> ProgressAdministration.reset(state, player, java.util.Set.of(Track.MASK))).get();
|
||||
tracker.checkpoint(player).get();
|
||||
assertEquals(0, states.snapshot().players().get(player).track(Track.MASK).progress());
|
||||
clock.set(6_000_000_000L);
|
||||
tracker.stop(player).get();
|
||||
assertEquals(1000, states.snapshot().players().get(player).track(Track.MASK).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkpointsRetainFractionalTimeUnlockAtLimitAndSurviveRestartWithoutOfflineCredit() throws Exception {
|
||||
UUID player = UUID.randomUUID();
|
||||
var clock = new AtomicLong();
|
||||
var repository = new FileStateRepository(directory.resolve("state.bin"));
|
||||
try (var states = StateService.open(repository).get()) {
|
||||
states.configure(2, 313, 1000).get();
|
||||
var tracker = new InvisibilityProgress(states, clock::get);
|
||||
tracker.drink(player);
|
||||
clock.set(600_000);
|
||||
tracker.checkpoint(player).get();
|
||||
clock.set(1_200_000);
|
||||
tracker.checkpoint(player).get();
|
||||
assertEquals(1, states.snapshot().players().get(player).track(Track.MASK).progress());
|
||||
clock.set(2_100_000);
|
||||
tracker.stop(player).get();
|
||||
assertTrue(states.snapshot().players().get(player).track(Track.MASK).unlocked());
|
||||
}
|
||||
clock.set(1_000_000_000_000L);
|
||||
try (var states = StateService.open(repository).get()) {
|
||||
var tracker = new InvisibilityProgress(states, clock::get);
|
||||
tracker.checkpoint(player).get();
|
||||
assertEquals(2, states.snapshot().players().get(player).track(Track.MASK).progress());
|
||||
tracker.drink(player);
|
||||
clock.addAndGet(3_000_000);
|
||||
tracker.stop(player).get();
|
||||
assertEquals(5, states.snapshot().players().get(player).track(Track.MASK).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedCheckpointRetainsElapsedCreditForRetry() throws Exception {
|
||||
UUID player = UUID.randomUUID();
|
||||
var clock = new AtomicLong();
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
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 {
|
||||
if (fail.get()) throw new java.io.IOException("Simulated failure");
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
try (var states = StateService.open(boundary).get()) {
|
||||
var tracker = new InvisibilityProgress(states, clock::get);
|
||||
tracker.drink(player);
|
||||
clock.set(5_000_000_000L);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> tracker.checkpoint(player).get());
|
||||
fail.set(false);
|
||||
clock.set(7_000_000_000L);
|
||||
tracker.stop(player).get();
|
||||
assertEquals(7000, states.snapshot().players().get(player).track(Track.MASK).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateCheckpointMustWaitForTheAlreadyReservedWrite() throws Exception {
|
||||
UUID player = UUID.randomUUID();
|
||||
var clock = new AtomicLong();
|
||||
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(3, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Timed out");
|
||||
} catch (InterruptedException exception) { Thread.currentThread().interrupt(); throw new java.io.IOException(exception); }
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
try (var states = StateService.open(boundary).get()) {
|
||||
var tracker = new InvisibilityProgress(states, clock::get);
|
||||
tracker.drink(player);
|
||||
clock.set(1_000_000);
|
||||
var first = tracker.checkpoint(player);
|
||||
assertTrue(entered.await(3, java.util.concurrent.TimeUnit.SECONDS));
|
||||
try {
|
||||
var duplicate = tracker.checkpoint(player);
|
||||
assertFalse(duplicate.isDone());
|
||||
release.countDown();
|
||||
assertEquals(first.get(), duplicate.get());
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshedDirectDrinksCountElapsedTimeOnlyOnceAndStopKeepsEarnedTime() throws Exception {
|
||||
UUID player = UUID.randomUUID();
|
||||
var clock = new AtomicLong();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
var tracker = new InvisibilityProgress(states, clock::get);
|
||||
tracker.drink(player);
|
||||
clock.set(2_000_000_000L);
|
||||
tracker.drink(player);
|
||||
clock.set(5_000_000_000L);
|
||||
tracker.stop(player).get();
|
||||
assertEquals(5000, states.snapshot().players().get(player).track(Track.MASK).progress());
|
||||
clock.set(50_000_000_000L);
|
||||
tracker.checkpoint(player).get();
|
||||
assertEquals(5000, states.snapshot().players().get(player).track(Track.MASK).progress());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MaskCraftingTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
|
||||
|
||||
@Test
|
||||
void recipeUsesEightNetheriteBlocksAndCentralNetherStarOnlyAfterSkinIsReady() {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
var items = mock(MaskItems.class);
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(server.addRecipe(recipe)).thenReturn(true);
|
||||
var crafting = new MaskCrafting(plugin, null, items, null, ignored -> recipe);
|
||||
crafting.run();
|
||||
verify(server, never()).addRecipe(any());
|
||||
when(items.ready()).thenReturn(true);
|
||||
crafting.run(); crafting.run();
|
||||
verify(recipe).shape("NNN", "NSN", "NNN");
|
||||
verify(recipe).setIngredient('N', org.bukkit.Material.NETHERITE_BLOCK);
|
||||
verify(recipe).setIngredient('S', org.bukkit.Material.NETHER_STAR);
|
||||
verify(server, times(1)).addRecipe(recipe);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validCraftIsLeftToVanillaButResetBetweenPreviewAndClickRejectsIt() throws Exception {
|
||||
var owner = java.util.UUID.randomUUID();
|
||||
var receipt = java.util.UUID.randomUUID();
|
||||
var token = new MaskToken(owner, receipt, 0);
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(recipe.getKey()).thenReturn(MaskCrafting.RECIPE);
|
||||
var items = mock(MaskItems.class);
|
||||
when(items.identify(null)).thenReturn(java.util.Optional.of(token));
|
||||
var event = mock(CraftItemEvent.class);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(event.getWhoClicked()).thenReturn(player);
|
||||
when(event.getRecipe()).thenReturn(recipe);
|
||||
when(event.getInventory()).thenReturn(inventory);
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
states.update(state -> MaskRewards.issue(state, owner, receipt, true)).get();
|
||||
var crafting = new MaskCrafting(mock(JavaPlugin.class), states, items, null);
|
||||
crafting.onCraft(event);
|
||||
verify(event, never()).setCancelled(true);
|
||||
verify(inventory, never()).setMatrix(any());
|
||||
states.update(state -> ProgressAdministration.reset(state, owner, java.util.Set.of(Track.MASK))).get();
|
||||
crafting.onCraft(event);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthenticatedPreviewAndAutomatedCrafterCannotConsumeIngredients() {
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(recipe.getKey()).thenReturn(MaskCrafting.RECIPE);
|
||||
var items = mock(MaskItems.class);
|
||||
var event = mock(CraftItemEvent.class);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
when(event.getRecipe()).thenReturn(recipe);
|
||||
when(event.getInventory()).thenReturn(inventory);
|
||||
when(event.getWhoClicked()).thenReturn(mock(Player.class));
|
||||
var crafting = new MaskCrafting(mock(JavaPlugin.class), null, items, null);
|
||||
crafting.onCraft(event);
|
||||
verify(event).setCancelled(true);
|
||||
verify(inventory).setResult(null);
|
||||
var crafter = mock(CrafterCraftEvent.class);
|
||||
when(crafter.getRecipe()).thenReturn(recipe);
|
||||
crafting.onCrafter(crafter);
|
||||
verify(crafter).setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import com.comphenix.protocol.wrappers.EnumWrappers;
|
||||
import com.comphenix.protocol.wrappers.Pair;
|
||||
import java.util.List;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MaskEquipmentTest {
|
||||
@Test
|
||||
void projectionHidesOnlyHeadAndDoesNotMutateServerEquipment() {
|
||||
var helmet = mock(ItemStack.class);
|
||||
var chest = mock(ItemStack.class);
|
||||
var empty = mock(ItemStack.class);
|
||||
var original = List.of(new Pair<>(EnumWrappers.ItemSlot.HEAD, helmet), new Pair<>(EnumWrappers.ItemSlot.CHEST, chest));
|
||||
var projected = MaskEquipmentListener.project(original, empty);
|
||||
assertSame(empty, projected.getFirst().getSecond());
|
||||
assertSame(chest, projected.get(1).getSecond());
|
||||
assertSame(helmet, original.getFirst().getSecond());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MaskItemsTest {
|
||||
@Test
|
||||
void existingItemIdentityDoesNotDependOnLiveSkinLookupAndNameOnlyHeadsAreRejected() {
|
||||
var items = new MaskItems();
|
||||
var item = mock(ItemStack.class);
|
||||
var meta = mock(SkullMeta.class);
|
||||
var tags = mock(PersistentDataContainer.class);
|
||||
when(item.getType()).thenReturn(Material.PLAYER_HEAD);
|
||||
when(item.hasItemMeta()).thenReturn(true);
|
||||
when(item.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getPersistentDataContainer()).thenReturn(tags);
|
||||
assertFalse(items.ready());
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
when(tags.get(any(), eq(PersistentDataType.STRING))).thenReturn(EchoIdentity.encode(new EchoToken(owner, receipt, 2)));
|
||||
assertEquals(new MaskToken(owner, receipt, 2), items.identify(item).orElseThrow());
|
||||
when(item.getType()).thenReturn(Material.ECHO_SHARD);
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 MaskRewardsTest {
|
||||
@Test
|
||||
void adminGrantUnlocksUseAndDeliveryAcknowledgementPreservesTheIssuedItem() {
|
||||
UUID player = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var granted = MaskRewards.issue(IgnisState.empty(), player, receipt, true);
|
||||
var token = new MaskToken(player, receipt, 0);
|
||||
assertTrue(MaskRewards.valid(granted, player, token));
|
||||
assertEquals(Track.MASK, granted.players().get(player).pendingRewards().get(receipt));
|
||||
assertTrue(MaskRewards.craftingToken(granted, player).isEmpty(),
|
||||
"A pending admin gift must not be reused for a paid craft and mistaken for gift delivery");
|
||||
var delivered = MaskRewards.acknowledge(granted, token);
|
||||
assertTrue(delivered.players().get(player).pendingRewards().isEmpty());
|
||||
assertTrue(MaskRewards.valid(delivered, player, token));
|
||||
assertEquals(token, MaskRewards.craftingToken(delivered, player).orElseThrow());
|
||||
assertEquals(delivered, MaskRewards.acknowledge(delivered, token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void craftingReceiptRequiresUnlockAndTradedMaskRequiresBothValidIssuanceAndHolderUnlock() {
|
||||
UUID issuer = UUID.randomUUID(), holder = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
assertThrows(IllegalArgumentException.class, () -> MaskRewards.issue(IgnisState.empty(), issuer, receipt, false));
|
||||
var unlocked = ProgressAdministration.progress(IgnisState.empty(), issuer, Track.MASK,
|
||||
ProgressLimits.defaults().invisibilityMillis(), false);
|
||||
var issued = MaskRewards.issue(unlocked, issuer, receipt, false);
|
||||
var token = new MaskToken(issuer, receipt, 0);
|
||||
assertTrue(MaskRewards.valid(issued, issuer, token));
|
||||
assertFalse(MaskRewards.valid(issued, holder, token));
|
||||
var holderUnlocked = ProgressAdministration.progress(issued, holder, Track.MASK,
|
||||
ProgressLimits.defaults().invisibilityMillis(), false);
|
||||
assertTrue(MaskRewards.valid(holderUnlocked, holder, token));
|
||||
var reset = ProgressAdministration.reset(holderUnlocked, issuer, Set.of(Track.MASK));
|
||||
var reearned = ProgressAdministration.progress(reset, issuer, Track.MASK,
|
||||
ProgressLimits.defaults().invisibilityMillis(), false);
|
||||
assertFalse(MaskRewards.valid(reearned, holder, token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.net.URI;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.profile.PlayerProfile;
|
||||
import org.bukkit.profile.PlayerTextures;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MaskSkinLoaderTest {
|
||||
@Test
|
||||
void resolvesOnlyApprovedSkinAsynchronouslyAndReusesReadyProfile() throws Exception {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
var pending = mock(PlayerProfile.class);
|
||||
when(server.createPlayerProfile("IgnisUmbra10")).thenReturn(pending);
|
||||
var response = new CompletableFuture<PlayerProfile>();
|
||||
doReturn(response).when(pending).update();
|
||||
var dispatch = mock(EchoGameplay.class);
|
||||
doAnswer(call -> { ((Runnable) call.getArgument(0)).run(); return null; }).when(dispatch).onServer(any());
|
||||
var items = new MaskItems();
|
||||
var loader = new MaskSkinLoader(plugin, items, dispatch);
|
||||
loader.run(); loader.run();
|
||||
assertFalse(items.ready());
|
||||
var resolved = mock(PlayerProfile.class);
|
||||
var textures = mock(PlayerTextures.class);
|
||||
when(resolved.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(resolved.getTextures()).thenReturn(textures);
|
||||
when(textures.getSkin()).thenReturn(URI.create("https://textures.minecraft.net/texture/synthetic").toURL());
|
||||
when(resolved.clone()).thenReturn(resolved);
|
||||
response.complete(resolved);
|
||||
assertTrue(items.ready());
|
||||
loader.run();
|
||||
verify(server, times(1)).createPlayerProfile("IgnisUmbra10");
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteProfileCannotEnableCrafting() {
|
||||
var items = new MaskItems();
|
||||
assertThrows(IllegalArgumentException.class, () -> items.profile(mock(PlayerProfile.class)));
|
||||
assertFalse(items.ready());
|
||||
assertThrows(IllegalStateException.class, items::prototype);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user