Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7edfe71d2d | ||
|
|
79c9c7fea7 | ||
|
|
17b61d7b43 | ||
|
|
183b6660b2 | ||
|
|
5ee5876830 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Spigot Ignis
|
||||
|
||||
Server-wide earned shadow progression for Social Minecraft. **Foundation work only; no player abilities or commands are implemented yet. Do not deploy this foundation as a gameplay release.**
|
||||
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss, advancement-name scrambling, The Mask, The Dagger, and The Heart of Shadows are implemented in source; live-server/client verification is pending.**
|
||||
|
||||
## Build
|
||||
|
||||
@@ -11,27 +11,106 @@ 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
|
||||
|
||||
The [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-ignis/index.md) and stories live in the shared private SoMC wiki. There is no repository-local knowledge bundle.
|
||||
|
||||
CI/release workflows are scaffolded but not remotely verified. Release publication requires the repository Actions secret `RELEASE_TOKEN`, securely provisioned separately before the first main push. The release workflow uses Git askpass and curl stdin for authentication rather than embedding a release credential in URLs or process arguments. Never enable tracing. GitOps and production release need separate approvals.
|
||||
CI/release workflows publish versioned source artifacts; verify their exact run/commit before using any artifact. Release publication requires the repository Actions secret `RELEASE_TOKEN`, provisioned separately. The release workflow uses a repository-scoped Git credential helper and curl stdin for authentication rather than embedding a release credential in URLs or process arguments. Semantic-release overrides `GIT_ASKPASS`, so askpass is not used for release authentication. Never enable tracing. GitOps and production release need separate approvals.
|
||||
|
||||
## Foundation interfaces and state
|
||||
|
||||
- `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, and pending reward identifiers. This is storage infrastructure, **not implemented reward delivery, item revocation, or challenge tracking**. Issuance/transfer 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.
|
||||
|
||||
The foundation bootstrap reads only `plugins/SpigotIgnis/state.bin`; it does not read or migrate Stealth data. No commands, crafting, reset handlers, event counters, restart challenge resets, or rendering hooks are registered. Runtime settings currently have a Java service API only, not a config-file editor or admin command.
|
||||
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.
|
||||
|
||||
## Echo gameplay
|
||||
|
||||
Take uncancelled void damage, ascend while gliding with elytra in the same world, then make the first landing on End Stone. Death, disconnect, teleport/world change, or restarting cancels an unfinished attempt. No datapack advancement ID is required.
|
||||
|
||||
The earned Echo Shard is named **Echo of the Abyss**, with cryptic lore and a UUID-bound receipt. Right-click it to activate permanent obfuscated-name advancement announcements. It does not hide general identity or alter advancement titles/descriptions. Only the bound owner may use it, and activation cannot grant twice. Lost, unconsumed rewards can be earned again by repeating the feat. Admins can also grant a bound Echo.
|
||||
|
||||
Full inventories retain a pending reward; online delivery retries every five seconds using the same receipt identity. Temporary inventory/drop/swap locks prevent ordinary moves during the short activation commit. Disk failures do not consume the item on activation; failed completion saves produce a player-facing recovery message rather than pretending the reward was saved.
|
||||
|
||||
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.
|
||||
|
||||
## 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 active item tracks. No passive progress UI is added.
|
||||
|
||||
### The Heart of Shadows
|
||||
|
||||
Earn the personal unlock through six uninterrupted online hours at total light zero (configurable). Uncancelled positive final damage, leaving darkness, disconnecting, or restarting interrupts unfinished progress. Completed unlocks persist until administrative reset. Light observation never loads chunks; unavailable lighting cannot qualify.
|
||||
|
||||
Craft a Creaking Heart surrounded orthogonally by four Ominous Bottle V items, with four Lingering Potions of Instant Health II in the corners. The registered recipe uses exact vanilla potion/bottle metadata, and preparation/click checks independently validate the matrix. Automated Crafters are denied; issued Hearts, including revoked copies, cannot be placed as blocks.
|
||||
|
||||
Carrying an authenticated Heart requires both the holder's unlock and a valid issuer receipt/generation. It grants Regeneration II at total light zero, or at night in natural skylit worlds. Multiple copies do not stack. Existing stronger regeneration is left alone; weaker regeneration overlaid by Ignis is restored with its remaining server-tick duration on withdrawal. External clears/replacements and cancellation take precedence. Cleanup and native potion-event ordering still require live verification.
|
||||
|
||||
Heart counters checkpoint periodically off-thread. Startup durably clears unfinished streaks before enabling gameplay; shutdown drains interruption writes before closing persistence. Administrator progress edits flush and rebase the active interval rather than replaying older elapsed progress.
|
||||
|
||||
Administration requires **`ignis.admin`** (default: operators), checked independently during execution and autocomplete:
|
||||
|
||||
```text
|
||||
/ignisadmin inspect <online-name|known-UUID>
|
||||
/ignisadmin limits
|
||||
/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|dagger|heart>
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all>
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all> confirm
|
||||
```
|
||||
|
||||
`inspect` reports saved progress for all four tracks (Mask/Heart milliseconds and Dagger pearls), pending/issued reward counts, recipe unlock eligibility, and Echo eligibility separately from permanent advancement-name-scrambling activation. It also reports the active void attempt: `awaiting-elytra-ascent`, `awaiting-end-stone`, or `none`, including world UUID and original damage height when active. Inspection is read-only: it does not checkpoint progress, advance/cancel challenges, deliver items or write state. Counters exclude uncommitted tracker time; recipe unlocks do not mean item effects are currently equipped or active. Known offline UUIDs remain inspectable; unfinished void attempts are not restored after disconnect/restart.
|
||||
|
||||
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.
|
||||
|
||||
### Recovery and format
|
||||
|
||||
`state.bin` is a versioned binary data format (not Java object serialization): magic/version, positive thresholds, UUID player maps, named track states, and UUID pending rewards. Invalid versions, counts, duplicate keys, malformed flags, negative counters, truncated input, and trailing bytes are rejected. Record counts are bounded at 100,000 players and 100,000 pending rewards per player.
|
||||
`state.bin` is a versioned binary data format (not Java object serialization): magic/version, positive thresholds, UUID player maps, named track states, pending rewards, and issuance receipts. Version 2 reads version 1 and carries forward all existing pending rewards as matching issuance receipts; the next successful save writes version 2. Invalid versions, counts, duplicate keys, malformed flags, negative counters, truncated input, and trailing bytes are rejected. Record counts are bounded at 100,000 players and 100,000 pending rewards/issuance receipts per player.
|
||||
|
||||
Writes use a same-directory temporary file, flush it to disk, and require atomic replacement. Unsupported atomic moves or write failures retain the prior active snapshot and fail the mutation; there is no non-atomic fallback. Abandoned temporary files are ignored, never auto-promoted. This does not guarantee survival of arbitrary storage hardware or power failures.
|
||||
|
||||
@@ -39,4 +118,8 @@ If loading fails, preserve the original file for investigation. Under an approve
|
||||
|
||||
## Verification limits
|
||||
|
||||
JUnit tests exercise real temporary-file persistence, serialized writes, fault injection, threshold policies, metadata, and static workflow guardrails. YAML/shell parsing is not remote CI verification. No Purpur startup/shutdown, live gameplay, client presentation, command registration, or actual release publishing has been tested yet.
|
||||
JUnit tests exercise real temporary-file persistence, serialized writes, fault injection, threshold policies, receipt/owner/reset rules, challenge transitions, announcement components, command execution/completion, mocked Bukkit boundaries, metadata, and static workflow guardrails. YAML/shell parsing is not remote CI verification.
|
||||
|
||||
Live Purpur startup/shutdown, event sequencing, item appearance/consumption, inventory durability across hard crashes, protections, and Stealth/client compatibility still require a manual smoke test. Minecraft inventory persistence and this plugin's state file are not a single atomic transaction: a hard crash between item delivery and Minecraft saving inventory needs operator recovery review. If disconnect/death happens during activation, the durable benefit can commit before the physical item is removed; any retained copy is inert, not another usable unlock. These limitations prevent claiming complete live/crash acceptance or deploying automatically.
|
||||
|
||||
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.
|
||||
@@ -15,9 +15,12 @@ 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")
|
||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Set;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.ComponentLike;
|
||||
import net.kyori.adventure.text.TranslatableComponent;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
|
||||
/** Semantic advancement name replacement; never search/replace advancement titles or user text. */
|
||||
public final class AdvancementMasker {
|
||||
private static final Set<String> KEYS = Set.of("chat.type.advancement.task", "chat.type.advancement.goal",
|
||||
"chat.type.advancement.challenge");
|
||||
private AdvancementMasker() { }
|
||||
|
||||
public static Component mask(Component original) {
|
||||
if (original == null) return null;
|
||||
Component result = original;
|
||||
boolean changed = false;
|
||||
if (result instanceof TranslatableComponent translation && KEYS.contains(translation.key())
|
||||
&& !translation.arguments().isEmpty()) {
|
||||
var arguments = new ArrayList<ComponentLike>(translation.arguments());
|
||||
arguments.set(0, Component.text("Anonymous").decorate(TextDecoration.OBFUSCATED).insertion(""));
|
||||
result = translation.arguments(arguments);
|
||||
changed = true;
|
||||
}
|
||||
var children = new ArrayList<Component>();
|
||||
for (Component child : original.children()) {
|
||||
Component masked = mask(child);
|
||||
children.add(masked);
|
||||
changed |= masked != child;
|
||||
}
|
||||
if (changed) {
|
||||
// Strip inherited identity actions along the name's ancestry; advancement args are untouched.
|
||||
return result.children(children).clickEvent(null).hoverEvent((net.kyori.adventure.text.event.HoverEvent<?>) null)
|
||||
.insertion(null);
|
||||
}
|
||||
return original;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerAdvancementDoneEvent;
|
||||
|
||||
public final class AnnouncementListener implements Listener {
|
||||
private final Predicate<UUID> activated;
|
||||
public AnnouncementListener(Predicate<UUID> activated) { this.activated = Objects.requireNonNull(activated); }
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onAdvancement(PlayerAdvancementDoneEvent event) {
|
||||
if (event.message() != null && activated.test(event.getPlayer().getUniqueId())) {
|
||||
event.message(AdvancementMasker.mask(event.message()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,28 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class DaggerCrafting extends ReceiptCrafting {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("spigotignis", "dagger");
|
||||
public DaggerCrafting(JavaPlugin plugin, StateService states, DaggerItems items, EchoGameplay dispatch) {
|
||||
this(plugin, states, items, dispatch, result -> new ShapedRecipe(RECIPE, result));
|
||||
}
|
||||
DaggerCrafting(JavaPlugin plugin, StateService states, DaggerItems items, EchoGameplay dispatch,
|
||||
Function<ItemStack, ShapedRecipe> recipeFactory) {
|
||||
super(plugin, states, items, dispatch, Track.DAGGER, RECIPE, recipeFactory);
|
||||
}
|
||||
@Override protected void configure(ShapedRecipe recipe) { configureRecipe(recipe); }
|
||||
static void configureRecipe(ShapedRecipe recipe) {
|
||||
recipe.shape("HCH", "NKN", "HEH");
|
||||
recipe.setIngredient('H', Material.HEART_OF_THE_SEA);
|
||||
recipe.setIngredient('C', Material.HEAVY_CORE);
|
||||
recipe.setIngredient('N', Material.NETHER_STAR);
|
||||
recipe.setIngredient('K', Material.OMINOUS_TRIAL_KEY);
|
||||
recipe.setIngredient('E', Material.DRAGON_EGG);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public final class DaggerItems extends ReceiptItems {
|
||||
public DaggerItems() { this(() -> new ItemStack(Material.OMINOUS_TRIAL_KEY)); }
|
||||
DaggerItems(Supplier<ItemStack> factory) {
|
||||
super(Material.OMINOUS_TRIAL_KEY, "dagger_identity", "The Dagger", factory);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Session identity prevents interrupted intervals from being replayed by queued writes. */
|
||||
public final class DarknessProgress {
|
||||
private final StateService states;
|
||||
private final LongSupplier clock;
|
||||
private final Map<UUID, Session> sessions = new HashMap<>();
|
||||
private static final class Session {
|
||||
final long generation;
|
||||
final DarknessStreak streak;
|
||||
DarknessStreak.Progress desired;
|
||||
boolean active;
|
||||
Session(long generation, long seed, LongSupplier clock) {
|
||||
this.generation = generation;
|
||||
streak = new DarknessStreak(clock, seed);
|
||||
desired = new DarknessStreak.Progress(seed, false);
|
||||
}
|
||||
}
|
||||
public DarknessProgress(StateService states, LongSupplier clock) { this.states = states; this.clock = clock; }
|
||||
private Session session(UUID player) {
|
||||
var track = states.snapshot().players().getOrDefault(player, PlayerState.empty()).track(Track.HEART);
|
||||
return sessions.compute(player, (ignored, old) -> old == null || old.generation != track.generation()
|
||||
? new Session(track.generation(), track.progress(), clock) : old);
|
||||
}
|
||||
public synchronized void observe(UUID player, boolean dark) {
|
||||
if (!dark) { interrupt(player); return; }
|
||||
Session session = session(player);
|
||||
session.active = true;
|
||||
session.desired = session.streak.sample(true, states.snapshot().limits().darknessMillis());
|
||||
}
|
||||
public synchronized void interrupt(UUID player) {
|
||||
Session session = session(player);
|
||||
if (session.desired.complete()) return;
|
||||
if (session.active || session.desired.millis() != 0) {
|
||||
sessions.put(player, new Session(session.generation, 0, clock));
|
||||
}
|
||||
}
|
||||
public synchronized CompletableFuture<IgnisState> edit(UUID player, long value, boolean add) {
|
||||
Session before = session(player);
|
||||
return checkpoint(player).thenCompose(ignored -> states.update(state ->
|
||||
ProgressAdministration.progress(state, player, Track.HEART, value, add))).thenApply(state -> {
|
||||
synchronized (this) { sessions.remove(player, before); }
|
||||
return state;
|
||||
});
|
||||
}
|
||||
private record Capture(Session session, DarknessStreak.Progress desired) { }
|
||||
private CompletableFuture<Void> batch;
|
||||
public synchronized CompletableFuture<Void> checkpointAll() {
|
||||
if (batch == null || batch.isDone()) batch = saveBatch();
|
||||
return batch;
|
||||
}
|
||||
public synchronized CompletableFuture<Void> stopAll() {
|
||||
new java.util.ArrayList<>(sessions.keySet()).forEach(this::interrupt);
|
||||
// Shutdown must persist the new interruptions even if an older batch is in flight.
|
||||
batch = saveBatch();
|
||||
return batch;
|
||||
}
|
||||
private CompletableFuture<Void> saveBatch() {
|
||||
var captured = new HashMap<UUID, Capture>();
|
||||
sessions.forEach((id, session) -> captured.put(id, new Capture(session, session.desired)));
|
||||
if (captured.isEmpty()) return CompletableFuture.completedFuture(null);
|
||||
return states.update(state -> {
|
||||
var players = new HashMap<>(state.players());
|
||||
captured.forEach((id, capture) -> {
|
||||
var current = players.getOrDefault(id, PlayerState.empty());
|
||||
var heart = current.track(Track.HEART);
|
||||
synchronized (this) {
|
||||
if (sessions.get(id) != capture.session() || heart.generation() != capture.session().generation || heart.unlocked()) return;
|
||||
}
|
||||
var tracks = new HashMap<>(current.tracks());
|
||||
tracks.put(Track.HEART, new TrackState(capture.desired().millis(), capture.desired().complete(), heart.activated(), heart.generation()));
|
||||
players.put(id, new PlayerState(tracks, current.pendingRewards(), current.issuedRewards()));
|
||||
});
|
||||
return new IgnisState(state.limits(), players).evaluate(state.limits());
|
||||
}).thenApply(ignored -> null);
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<IgnisState> checkpoint(UUID player) {
|
||||
Session session = session(player);
|
||||
var desired = session.desired;
|
||||
return states.update(state -> {
|
||||
var current = state.players().getOrDefault(player, PlayerState.empty());
|
||||
var heart = current.track(Track.HEART);
|
||||
synchronized (this) {
|
||||
if (sessions.get(player) != session || heart.generation() != session.generation || heart.unlocked()) return state;
|
||||
}
|
||||
var tracks = new HashMap<>(current.tracks());
|
||||
tracks.put(Track.HEART, new TrackState(desired.millis(), desired.complete(), heart.activated(), heart.generation()));
|
||||
var players = new HashMap<>(state.players());
|
||||
players.put(player, new PlayerState(tracks, current.pendingRewards(), current.issuedRewards()));
|
||||
return new IgnisState(state.limits(), players).evaluate(state.limits());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Online-session clock only; persistence and environmental observations are separate adapters. */
|
||||
public final class DarknessStreak {
|
||||
public record Progress(long millis, boolean complete) { }
|
||||
private final LongSupplier clock;
|
||||
private boolean active;
|
||||
private long started;
|
||||
private Progress progress = new Progress(0, false);
|
||||
public DarknessStreak(LongSupplier clock) { this.clock = clock; }
|
||||
private long baseMillis;
|
||||
public DarknessStreak(LongSupplier clock, long seedMillis) {
|
||||
this(clock);
|
||||
if (seedMillis < 0) throw new IllegalArgumentException("Negative streak seed.");
|
||||
baseMillis = seedMillis;
|
||||
progress = new Progress(seedMillis, false);
|
||||
}
|
||||
public Progress sample(boolean dark, long requiredMillis) {
|
||||
if (requiredMillis <= 0) throw new IllegalArgumentException("Darkness limit must be positive.");
|
||||
if (progress.complete()) return progress;
|
||||
if (!dark) return interrupt();
|
||||
long now = clock.getAsLong();
|
||||
if (!active) { started = now; active = true; }
|
||||
long elapsed = Math.max(progress.millis(), Math.addExact(baseMillis, Math.max(0, now - started) / 1_000_000));
|
||||
progress = new Progress(elapsed, elapsed >= requiredMillis);
|
||||
return progress;
|
||||
}
|
||||
public Progress interrupt() {
|
||||
active = false;
|
||||
if (!progress.complete()) {
|
||||
baseMillis = 0;
|
||||
progress = new Progress(0, false);
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
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.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Bukkit work stays on the tick thread; state transactions contain no Bukkit access. */
|
||||
public final class EchoGameplay implements Listener {
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final EchoItems items;
|
||||
private final VoidTrial trial = new VoidTrial();
|
||||
private final Set<EchoToken> delivering = new HashSet<>();
|
||||
private final Set<UUID> activating = new HashSet<>();
|
||||
|
||||
public EchoGameplay(JavaPlugin plugin, StateService states, EchoItems items) {
|
||||
this.plugin = plugin;
|
||||
this.states = states;
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player player && event.getCause() == EntityDamageEvent.DamageCause.VOID
|
||||
&& event.getFinalDamage() > 0 && !activated(player.getUniqueId())) {
|
||||
trial.damaged(player.getUniqueId(), player.getWorld().getUID(), player.getY());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
if (event instanceof PlayerTeleportEvent || !event.hasChangedPosition()) return;
|
||||
Player player = event.getPlayer();
|
||||
if (player.isDead() || activated(player.getUniqueId())) {
|
||||
cancel(player.getUniqueId());
|
||||
return;
|
||||
}
|
||||
var location = event.getTo();
|
||||
boolean grounded = player.isOnGround();
|
||||
boolean endStone = grounded && location.clone().subtract(0, 0.1, 0).getBlock().getType() == Material.END_STONE;
|
||||
if (trial.moved(player.getUniqueId(), location.getWorld().getUID(), location.getY(), player.isGliding(),
|
||||
grounded, endStone)) {
|
||||
UUID owner = player.getUniqueId(), receipt = UUID.randomUUID();
|
||||
long generation = states.snapshot().players().getOrDefault(owner, PlayerState.empty()).track(Track.VOID).generation();
|
||||
states.update(state -> state.players().getOrDefault(owner, PlayerState.empty()).track(Track.VOID).generation() == generation
|
||||
? EchoRewards.award(state, owner, receipt) : state).whenComplete((state, error) -> onServer(() -> {
|
||||
if (error != null) {
|
||||
plugin.getLogger().warning("Echo reward save failed; administrator recovery is required.");
|
||||
Player online = plugin.getServer().getPlayer(owner);
|
||||
if (online != null) online.sendMessage("Your void reward could not be saved. Please contact an administrator.");
|
||||
} else retryDeliveries();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onTeleport(PlayerTeleportEvent event) { cancel(event.getPlayer().getUniqueId()); }
|
||||
@EventHandler public void onDeath(PlayerDeathEvent event) { cancel(event.getEntity().getUniqueId()); }
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) { cancel(event.getPlayer().getUniqueId()); }
|
||||
public void cancel(UUID player) { trial.cancel(player); }
|
||||
|
||||
/** Read-only server-thread inspection; does not advance, cancel or persist the attempt. */
|
||||
public java.util.Optional<VoidTrial.Snapshot> trialSnapshot(UUID player) {
|
||||
return trial.snapshot(player);
|
||||
}
|
||||
|
||||
public boolean activated(UUID player) {
|
||||
return states.snapshot().players().getOrDefault(player, PlayerState.empty()).track(Track.VOID).activated();
|
||||
}
|
||||
|
||||
/** Rechecks full inventories periodically and retries acknowledgements using stable receipt IDs. */
|
||||
public void retryDeliveries() {
|
||||
for (Player player : plugin.getServer().getOnlinePlayers()) {
|
||||
if (activating.contains(player.getUniqueId())) continue;
|
||||
PlayerState state = states.snapshot().players().getOrDefault(player.getUniqueId(), PlayerState.empty());
|
||||
if (state.track(Track.VOID).activated()) continue;
|
||||
for (var entry : state.pendingRewards().entrySet()) {
|
||||
if (entry.getValue() != Track.VOID) continue;
|
||||
EchoToken token = new EchoToken(player.getUniqueId(), entry.getKey(), state.track(Track.VOID).generation());
|
||||
if (delivering.contains(token) || !items.offer(player, token)) continue;
|
||||
delivering.add(token);
|
||||
states.update(current -> EchoRewards.acknowledge(current, token)).whenComplete((ignored, error) ->
|
||||
onServer(() -> delivering.remove(token)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onUse(PlayerInteractEvent event) {
|
||||
if (!event.getAction().isRightClick() || event.useItemInHand() == Event.Result.DENY) return;
|
||||
var identity = items.identify(event.getItem());
|
||||
if (identity.isEmpty()) return;
|
||||
event.setCancelled(true);
|
||||
Player player = event.getPlayer();
|
||||
EchoToken token = identity.orElseThrow();
|
||||
UUID actor = player.getUniqueId();
|
||||
if (activating.contains(actor)) return;
|
||||
if (!EchoRewards.valid(states.snapshot(), actor, token)) {
|
||||
player.sendMessage("This Echo cannot answer you.");
|
||||
return;
|
||||
}
|
||||
activating.add(actor);
|
||||
states.update(state -> EchoRewards.activate(state, actor, token)).whenComplete((ignored, error) -> onServer(() -> {
|
||||
activating.remove(actor);
|
||||
Player online = plugin.getServer().getPlayer(actor);
|
||||
if (online == null) return;
|
||||
TrackState current = states.snapshot().players().getOrDefault(actor, PlayerState.empty()).track(Track.VOID);
|
||||
if (error == null && current.activated() && current.generation() == token.generation()) {
|
||||
items.consumeOne(online, token);
|
||||
online.sendMessage("The abyss remembers. Your deeds will speak alone.");
|
||||
} else if (error == null) {
|
||||
online.sendMessage("Your Echo activation was superseded by an administrator reset.");
|
||||
} else online.sendMessage("The Echo remains silent. It has not been consumed; try again.");
|
||||
}));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onInventory(InventoryClickEvent event) {
|
||||
if (activating.contains(event.getWhoClicked().getUniqueId())) event.setCancelled(true);
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onDrag(InventoryDragEvent event) {
|
||||
if (activating.contains(event.getWhoClicked().getUniqueId())) event.setCancelled(true);
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onDrop(PlayerDropItemEvent event) {
|
||||
if (activating.contains(event.getPlayer().getUniqueId())) event.setCancelled(true);
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onSwap(PlayerSwapHandItemsEvent event) {
|
||||
if (activating.contains(event.getPlayer().getUniqueId())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
public void onServer(Runnable task) {
|
||||
if (!plugin.isEnabled()) return;
|
||||
try { plugin.getServer().getScheduler().runTask(plugin, task); }
|
||||
catch (org.bukkit.plugin.IllegalPluginAccessException ignored) { /* shutdown owns cleanup */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class EchoIdentity {
|
||||
private EchoIdentity() { }
|
||||
public static String encode(EchoToken token) {
|
||||
return token.owner() + ":" + token.receipt() + ":" + token.generation();
|
||||
}
|
||||
public static Optional<EchoToken> decode(String value) {
|
||||
if (value == null || value.length() > 110) return Optional.empty();
|
||||
String[] parts = value.split(":", -1);
|
||||
if (parts.length != 3) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(new EchoToken(UUID.fromString(parts[0]), UUID.fromString(parts[1]),
|
||||
Long.parseLong(parts[2])));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.List;
|
||||
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.persistence.PersistentDataType;
|
||||
|
||||
/** Thin inventory adapter. Receipt validity is checked against durable state, not lore. */
|
||||
public final class EchoItems {
|
||||
private static final NamespacedKey IDENTITY = new NamespacedKey("spigotignis", "echo_identity");
|
||||
|
||||
public ItemStack create(EchoToken token) {
|
||||
var item = new ItemStack(Material.ECHO_SHARD);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text("Echo of the Abyss", NamedTextColor.DARK_PURPLE));
|
||||
meta.lore(List.of(Component.text("You returned. Something followed.", NamedTextColor.GRAY),
|
||||
Component.text("Offer it your name, and let your deeds speak alone.", NamedTextColor.GRAY)));
|
||||
meta.getPersistentDataContainer().set(IDENTITY, PersistentDataType.STRING, EchoIdentity.encode(token));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
public Optional<EchoToken> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.ECHO_SHARD || !item.hasItemMeta()) return Optional.empty();
|
||||
return EchoIdentity.decode(item.getItemMeta().getPersistentDataContainer().get(IDENTITY, PersistentDataType.STRING));
|
||||
}
|
||||
|
||||
public boolean contains(Player player, EchoToken token) {
|
||||
for (ItemStack item : player.getInventory().getContents()) {
|
||||
if (identify(item).filter(token::equals).isPresent()) return true;
|
||||
}
|
||||
return identify(player.getItemOnCursor()).filter(token::equals).isPresent();
|
||||
}
|
||||
|
||||
public boolean offer(Player player, EchoToken token) {
|
||||
if (contains(player, token)) return true;
|
||||
if (player.getInventory().firstEmpty() < 0) return false;
|
||||
return player.getInventory().addItem(create(token)).isEmpty();
|
||||
}
|
||||
|
||||
public void consumeOne(Player player, EchoToken token) {
|
||||
for (int slot = 0; slot < player.getInventory().getSize(); slot++) {
|
||||
ItemStack item = player.getInventory().getItem(slot);
|
||||
if (identify(item).filter(token::equals).isPresent()) {
|
||||
item.setAmount(item.getAmount() - 1);
|
||||
player.getInventory().setItem(slot, item.getAmount() == 0 ? null : item);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ItemStack cursor = player.getItemOnCursor();
|
||||
if (identify(cursor).filter(token::equals).isPresent()) {
|
||||
cursor.setAmount(cursor.getAmount() - 1);
|
||||
player.setItemOnCursor(cursor.getAmount() == 0 ? null : cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.HashMap;
|
||||
|
||||
public final class EchoRewards {
|
||||
private EchoRewards() { }
|
||||
public static IgnisState award(IgnisState state, UUID owner, UUID receipt) {
|
||||
PlayerState player = state.players().getOrDefault(owner, PlayerState.empty());
|
||||
TrackState progress = player.track(Track.VOID);
|
||||
if (progress.activated() || player.issuedRewards().containsKey(receipt)) return state;
|
||||
var tracks = new HashMap<>(player.tracks());
|
||||
tracks.put(Track.VOID, new TrackState(1, true, false, progress.generation()));
|
||||
var pending = new HashMap<>(player.pendingRewards());
|
||||
var issued = new HashMap<>(player.issuedRewards());
|
||||
pending.put(receipt, Track.VOID);
|
||||
issued.put(receipt, Track.VOID);
|
||||
return replace(state, owner, new PlayerState(tracks, pending, issued));
|
||||
}
|
||||
|
||||
private static IgnisState replace(IgnisState state, UUID owner, PlayerState player) {
|
||||
var players = new HashMap<>(state.players());
|
||||
players.put(owner, player);
|
||||
return new IgnisState(state.limits(), players);
|
||||
}
|
||||
public static boolean valid(IgnisState state, UUID actor, EchoToken token) {
|
||||
if (!actor.equals(token.owner())) return false;
|
||||
PlayerState player = state.players().getOrDefault(actor, PlayerState.empty());
|
||||
TrackState track = player.track(Track.VOID);
|
||||
return track.unlocked() && !track.activated() && track.generation() == token.generation()
|
||||
&& player.issuedRewards().get(token.receipt()) == Track.VOID;
|
||||
}
|
||||
|
||||
public static IgnisState acknowledge(IgnisState state, EchoToken token) {
|
||||
if (!valid(state, token.owner(), token)) return state;
|
||||
PlayerState player = state.players().get(token.owner());
|
||||
var pending = new HashMap<>(player.pendingRewards());
|
||||
pending.remove(token.receipt());
|
||||
return replace(state, token.owner(), new PlayerState(player.tracks(), pending, player.issuedRewards()));
|
||||
}
|
||||
|
||||
public static IgnisState activate(IgnisState state, UUID actor, EchoToken token) {
|
||||
if (!valid(state, actor, token)) throw new IllegalArgumentException("This Echo cannot answer you.");
|
||||
PlayerState player = state.players().get(actor);
|
||||
var tracks = new HashMap<>(player.tracks());
|
||||
TrackState previous = player.track(Track.VOID);
|
||||
tracks.put(Track.VOID, new TrackState(previous.progress(), true, true, previous.generation()));
|
||||
var pending = new HashMap<>(player.pendingRewards());
|
||||
pending.entrySet().removeIf(entry -> entry.getValue() == Track.VOID);
|
||||
return replace(state, actor, new PlayerState(tracks, pending, player.issuedRewards()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public record EchoToken(UUID owner, UUID receipt, long generation) {
|
||||
public EchoToken {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(receipt);
|
||||
if (generation < 0) throw new IllegalArgumentException("Invalid Echo generation.");
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import java.util.UUID;
|
||||
/** Versioned data, never Java object deserialization. Call from the persistence worker only. */
|
||||
public final class FileStateRepository implements StateRepository {
|
||||
private static final int MAGIC = 0x49474e49;
|
||||
private static final int VERSION = 1;
|
||||
private static final int VERSION = 2;
|
||||
private static final int MAX_RECORDS = 100_000;
|
||||
private final Path path;
|
||||
|
||||
@@ -27,9 +27,9 @@ public final class FileStateRepository implements StateRepository {
|
||||
public IgnisState load() throws IOException {
|
||||
if (Files.notExists(path)) return IgnisState.empty();
|
||||
try (var input = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))) {
|
||||
if (input.readInt() != MAGIC || input.readInt() != VERSION) {
|
||||
throw new IOException("Unsupported Ignis state header or version.");
|
||||
}
|
||||
if (input.readInt() != MAGIC) throw new IOException("Unsupported Ignis state header.");
|
||||
int version = input.readInt();
|
||||
if (version < 1 || version > VERSION) throw new IOException("Unsupported Ignis state version.");
|
||||
var limits = new ProgressLimits(input.readLong(), input.readLong(), input.readLong());
|
||||
Map<UUID, PlayerState> players = new HashMap<>();
|
||||
int count = count(input, MAX_RECORDS);
|
||||
@@ -50,7 +50,20 @@ public final class FileStateRepository implements StateRepository {
|
||||
throw new IOException("Duplicate pending reward.");
|
||||
}
|
||||
}
|
||||
if (players.putIfAbsent(id, new PlayerState(tracks, pending)) != null) {
|
||||
Map<UUID, Track> issued = new HashMap<>(pending);
|
||||
if (version >= 2) {
|
||||
issued.clear();
|
||||
int receipts = count(input, MAX_RECORDS);
|
||||
for (int j = 0; j < receipts; j++) {
|
||||
if (issued.putIfAbsent(uuid(input), Track.valueOf(input.readUTF())) != null) {
|
||||
throw new IOException("Duplicate issued receipt.");
|
||||
}
|
||||
}
|
||||
if (!issued.entrySet().containsAll(pending.entrySet())) {
|
||||
throw new IOException("Pending reward has no matching issuance receipt.");
|
||||
}
|
||||
}
|
||||
if (players.putIfAbsent(id, new PlayerState(tracks, pending, issued)) != null) {
|
||||
throw new IOException("Duplicate player.");
|
||||
}
|
||||
}
|
||||
@@ -64,7 +77,8 @@ public final class FileStateRepository implements StateRepository {
|
||||
@Override
|
||||
public void save(IgnisState state) throws IOException {
|
||||
if (state.players().size() > MAX_RECORDS
|
||||
|| state.players().values().stream().anyMatch(p -> p.pendingRewards().size() > MAX_RECORDS)) {
|
||||
|| state.players().values().stream().anyMatch(p -> p.pendingRewards().size() > MAX_RECORDS
|
||||
|| p.issuedRewards().size() > MAX_RECORDS)) {
|
||||
throw new IOException("Ignis state exceeds supported record count.");
|
||||
}
|
||||
Files.createDirectories(path.getParent());
|
||||
@@ -92,6 +106,11 @@ public final class FileStateRepository implements StateRepository {
|
||||
uuid(output, reward.getKey());
|
||||
output.writeUTF(reward.getValue().name());
|
||||
}
|
||||
output.writeInt(entry.getValue().issuedRewards().size());
|
||||
for (var receipt : entry.getValue().issuedRewards().entrySet()) {
|
||||
uuid(output, receipt.getKey());
|
||||
output.writeUTF(receipt.getValue().name());
|
||||
}
|
||||
}
|
||||
}
|
||||
try (var file = FileChannel.open(temporary, StandardOpenOption.WRITE)) { file.force(true); }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
/** Night only supplies the alternative activation condition in normal day/night worlds. */
|
||||
public final class HeartConditions {
|
||||
private HeartConditions() { }
|
||||
public static boolean active(boolean darkness, boolean normalWorld, boolean night) { return darkness || (normalWorld && night); }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class HeartCrafting extends ReceiptCrafting {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("spigotignis", "heart");
|
||||
public HeartCrafting(JavaPlugin plugin, StateService states, HeartItems items, EchoGameplay dispatch) {
|
||||
super(plugin, states, items, dispatch, Track.HEART, RECIPE, result -> new ShapedRecipe(RECIPE, result));
|
||||
}
|
||||
@Override protected boolean accepts(org.bukkit.inventory.ItemStack[] matrix) { return HeartIngredients.matrix(matrix); }
|
||||
@Override protected void configure(ShapedRecipe recipe) {
|
||||
var bottle = new org.bukkit.inventory.ItemStack(org.bukkit.Material.OMINOUS_BOTTLE);
|
||||
var omen = (org.bukkit.inventory.meta.OminousBottleMeta) bottle.getItemMeta();
|
||||
omen.setAmplifier(4);
|
||||
bottle.setItemMeta(omen);
|
||||
var potion = new org.bukkit.inventory.ItemStack(org.bukkit.Material.LINGERING_POTION);
|
||||
var meta = (org.bukkit.inventory.meta.PotionMeta) potion.getItemMeta();
|
||||
meta.setBasePotionType(org.bukkit.potion.PotionType.STRONG_HEALING);
|
||||
potion.setItemMeta(meta);
|
||||
configureRecipe(recipe, new RecipeChoice.ExactChoice(bottle), new RecipeChoice.ExactChoice(potion));
|
||||
}
|
||||
static void configureRecipe(ShapedRecipe recipe, RecipeChoice bottle, RecipeChoice healing) {
|
||||
recipe.shape("PBP", "BCB", "PBP");
|
||||
recipe.setIngredient('B', bottle);
|
||||
recipe.setIngredient('P', healing);
|
||||
recipe.setIngredient('C', org.bukkit.Material.CREAKING_HEART);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
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.plugin.java.JavaPlugin;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
public final class HeartEffects implements Listener, Runnable {
|
||||
private final JavaPlugin plugin;
|
||||
private final HeartRegeneration regeneration;
|
||||
private final Predicate<Player> eligible;
|
||||
private final Function<Player, HeartRegeneration.Channel> channels;
|
||||
|
||||
public HeartEffects(JavaPlugin plugin, StateService states, HeartItems items) {
|
||||
this(plugin, new HeartRegeneration(() -> Integer.toUnsignedLong(plugin.getServer().getCurrentTick())), player -> {
|
||||
var world = player.getWorld();
|
||||
var tokens = Arrays.stream(player.getInventory().getContents()).flatMap(item -> items.identify(item).stream()).toList();
|
||||
return !player.isDead() && HeartEligibility.eligible(states.snapshot(), player.getUniqueId(), tokens,
|
||||
HeartEnvironment.dark(player.getLocation()),
|
||||
world.isNatural() && world.hasSkyLight(), !world.isDayTime());
|
||||
}, HeartEffects::channel);
|
||||
}
|
||||
HeartEffects(JavaPlugin plugin, HeartRegeneration regeneration, Predicate<Player> eligible,
|
||||
Function<Player, HeartRegeneration.Channel> channels) {
|
||||
this.plugin = plugin; this.regeneration = regeneration; this.eligible = eligible; this.channels = channels;
|
||||
}
|
||||
@Override public void run() {
|
||||
for (var player : plugin.getServer().getOnlinePlayers()) {
|
||||
regeneration.tick(player.getUniqueId(), eligible.test(player), channels.apply(player));
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onEffect(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() instanceof Player player && event.getModifiedType().equals(PotionEffectType.REGENERATION)) {
|
||||
regeneration.changed(player.getUniqueId(), event.getAction() != EntityPotionEffectEvent.Action.CHANGED || event.isOverride(),
|
||||
event.getNewEffect() != null);
|
||||
}
|
||||
}
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) { withdraw(event.getPlayer()); }
|
||||
private void withdraw(Player player) {
|
||||
regeneration.tick(player.getUniqueId(), false, channels.apply(player));
|
||||
regeneration.forget(player.getUniqueId());
|
||||
}
|
||||
public void close() { plugin.getServer().getOnlinePlayers().forEach(this::withdraw); }
|
||||
|
||||
private static HeartRegeneration.Channel channel(Player player) {
|
||||
return new HeartRegeneration.Channel() {
|
||||
@Override public HeartRegeneration.Effect current() {
|
||||
var current = player.getPotionEffect(PotionEffectType.REGENERATION);
|
||||
return current == null ? null : new HeartRegeneration.Effect(current.getAmplifier(), current.getDuration(),
|
||||
current.isAmbient(), current.hasParticles(), current.hasIcon());
|
||||
}
|
||||
@Override public void apply(HeartRegeneration.Effect effect) {
|
||||
player.addPotionEffect(new PotionEffect(PotionEffectType.REGENERATION, effect.ticks(), effect.amplifier(),
|
||||
effect.ambient(), effect.particles(), effect.icon()));
|
||||
}
|
||||
@Override public void remove() { player.removePotionEffect(PotionEffectType.REGENERATION); }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class HeartEligibility {
|
||||
private static final TradableRewards REWARDS = new TradableRewards(Track.HEART);
|
||||
private HeartEligibility() { }
|
||||
public static boolean eligible(IgnisState state, UUID holder, Collection<ItemToken> items,
|
||||
boolean dark, boolean normalWorld, boolean night) {
|
||||
return HeartConditions.active(dark, normalWorld, night)
|
||||
&& items.stream().anyMatch(token -> REWARDS.valid(state, holder, token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.Location;
|
||||
|
||||
/** Read-only light observation; never loads a chunk to establish eligibility. */
|
||||
public final class HeartEnvironment {
|
||||
private HeartEnvironment() { }
|
||||
public static boolean dark(Location location) {
|
||||
if (location == null || location.getWorld() == null) return false;
|
||||
var world = location.getWorld();
|
||||
return world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)
|
||||
&& world.getBlockAt(location.getBlockX(), location.getBlockY(), location.getBlockZ()).getLightLevel() == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public final class HeartIngredients {
|
||||
private HeartIngredients() { }
|
||||
public static boolean matrix(ItemStack[] items) {
|
||||
if (items == null || items.length != 9 || items[4] == null || items[4].getType() != org.bukkit.Material.CREAKING_HEART) return false;
|
||||
for (int slot : new int[]{0, 2, 6, 8}) if (!healing(items[slot])) return false;
|
||||
for (int slot : new int[]{1, 3, 5, 7}) if (!bottle(items[slot])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean bottle(ItemStack item) {
|
||||
return item != null && item.getType() == org.bukkit.Material.OMINOUS_BOTTLE
|
||||
&& item.getItemMeta() instanceof org.bukkit.inventory.meta.OminousBottleMeta meta
|
||||
&& meta.hasAmplifier() && meta.getAmplifier() == 4;
|
||||
}
|
||||
public static boolean healing(ItemStack item) {
|
||||
return item != null && item.getType() == org.bukkit.Material.LINGERING_POTION
|
||||
&& item.getItemMeta() instanceof org.bukkit.inventory.meta.PotionMeta meta
|
||||
&& meta.getBasePotionType() == org.bukkit.potion.PotionType.STRONG_HEALING && !meta.hasCustomEffects();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public final class HeartItems extends ReceiptItems {
|
||||
public HeartItems() { this(() -> new ItemStack(Material.CREAKING_HEART)); }
|
||||
HeartItems(Supplier<ItemStack> factory) {
|
||||
super(Material.CREAKING_HEART, "heart_identity", "The Heart of Shadows", factory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
|
||||
/** Issued Hearts are inventory rewards, not placeable vanilla blocks. */
|
||||
public final class HeartPlacement implements Listener {
|
||||
private final HeartItems items;
|
||||
public HeartPlacement(HeartItems items) { this.items = items; }
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onPlace(BlockPlaceEvent event) {
|
||||
if (items.identify(event.getItemInHand()).isPresent()) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import org.bukkit.Location;
|
||||
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.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Main-thread observations; persistence is delegated to the serialized state worker. */
|
||||
public final class HeartProgressListener implements Listener, Runnable {
|
||||
private final JavaPlugin plugin;
|
||||
private final DarknessProgress progress;
|
||||
private final Predicate<Location> dark;
|
||||
private final Consumer<Throwable> errors;
|
||||
public HeartProgressListener(JavaPlugin plugin, DarknessProgress progress, Consumer<Throwable> errors) {
|
||||
this(plugin, progress, HeartEnvironment::dark, errors);
|
||||
}
|
||||
HeartProgressListener(JavaPlugin plugin, DarknessProgress progress, Predicate<Location> dark, Consumer<Throwable> errors) {
|
||||
this.plugin = plugin; this.progress = progress; this.dark = dark; this.errors = errors;
|
||||
}
|
||||
@Override public void run() {
|
||||
for (var player : plugin.getServer().getOnlinePlayers()) {
|
||||
progress.observe(player.getUniqueId(), !player.isDead() && dark.test(player.getLocation()));
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageEvent event) {
|
||||
if (!event.isCancelled() && event.getFinalDamage() > 0 && event.getEntity() instanceof Player player) {
|
||||
interruptAndSave(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
if (!event.isCancelled() && event.getTo() != null && !dark.test(event.getTo())) {
|
||||
progress.interrupt(event.getPlayer().getUniqueId());
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onTeleport(PlayerTeleportEvent event) { onMove(event); }
|
||||
@EventHandler public void onQuit(PlayerQuitEvent event) { interruptAndSave(event.getPlayer().getUniqueId()); }
|
||||
private void interruptAndSave(UUID player) {
|
||||
progress.interrupt(player);
|
||||
progress.checkpoint(player).whenComplete((ignored, error) -> { if (error != null) errors.accept(error); });
|
||||
}
|
||||
public void checkpoint() {
|
||||
progress.checkpointAll().whenComplete((ignored, error) -> { if (error != null) errors.accept(error); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
/** Durable restart normalization for unfinished online-only darkness streaks. */
|
||||
public final class HeartProgressState {
|
||||
private HeartProgressState() { }
|
||||
public static java.util.concurrent.CompletableFuture<StateService> prepare(StateService service) {
|
||||
return service.update(HeartProgressState::restart).handle((state, error) -> {
|
||||
if (error != null) {
|
||||
service.close();
|
||||
throw new java.util.concurrent.CompletionException(error);
|
||||
}
|
||||
return service;
|
||||
});
|
||||
}
|
||||
public static IgnisState restart(IgnisState state) {
|
||||
var players = new java.util.HashMap<>(state.players());
|
||||
players.replaceAll((id, player) -> {
|
||||
var heart = player.track(Track.HEART);
|
||||
if (heart.unlocked() || heart.progress() == 0) return player;
|
||||
var tracks = new java.util.HashMap<>(player.tracks());
|
||||
tracks.put(Track.HEART, new TrackState(0, false, heart.activated(), heart.generation()));
|
||||
return new PlayerState(tracks, player.pendingRewards(), player.issuedRewards());
|
||||
});
|
||||
return new IgnisState(state.limits(), players);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Regeneration II overlay with restoration of an existing weaker effect. */
|
||||
public final class HeartRegeneration {
|
||||
public record Effect(int amplifier, int ticks, boolean ambient, boolean particles, boolean icon) { }
|
||||
public interface Channel {
|
||||
Effect current();
|
||||
void apply(Effect effect);
|
||||
void remove();
|
||||
}
|
||||
private record Backup(Effect effect, long tick) { }
|
||||
private final LongSupplier ticks;
|
||||
private final EffectOwnership ownership = new EffectOwnership();
|
||||
private final java.util.Set<UUID> changing = new java.util.HashSet<>();
|
||||
private final java.util.Map<UUID, Backup> backups = new java.util.HashMap<>();
|
||||
public HeartRegeneration(LongSupplier ticks) { this.ticks = ticks; }
|
||||
public void changed(UUID player, boolean actual, boolean remains) {
|
||||
if (actual && !changing.contains(player)) backups.remove(player);
|
||||
ownership.changed(player, actual, remains);
|
||||
}
|
||||
public void tick(UUID player, boolean eligible, Channel channel) {
|
||||
var current = channel.current();
|
||||
if (!eligible) {
|
||||
if (!ownership.owns(player)) return;
|
||||
mutate(player, channel::remove);
|
||||
var after = channel.current();
|
||||
if (after != null) {
|
||||
if (!after.equals(current)) forget(player); // Preserve an external replacement.
|
||||
return; // Removal was cancelled or another effect is now present.
|
||||
}
|
||||
ownership.forget(player); // Disabled plugins may not receive removal events.
|
||||
Backup backup = backups.remove(player);
|
||||
if (backup != null) {
|
||||
long elapsed = (ticks.getAsLong() - backup.tick()) & 0xffff_ffffL;
|
||||
int remaining = backup.effect().ticks() == -1 ? -1 : (int) Math.max(0L, backup.effect().ticks() - elapsed);
|
||||
if (remaining != 0) {
|
||||
Effect old = backup.effect();
|
||||
channel.apply(new Effect(old.amplifier(), remaining, old.ambient(), old.particles(), old.icon()));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current == null || current.amplifier() < 1
|
||||
|| ownership.owns(player) && current.ticks() >= 0 && current.ticks() <= 20) {
|
||||
if (!ownership.owns(player) && current != null) backups.put(player, new Backup(current, ticks.getAsLong()));
|
||||
mutate(player, () -> channel.apply(new Effect(1, 40, true, false, true)));
|
||||
if (!ownership.owns(player)) backups.remove(player);
|
||||
}
|
||||
}
|
||||
public void forget(UUID player) {
|
||||
ownership.forget(player);
|
||||
changing.remove(player);
|
||||
backups.remove(player);
|
||||
}
|
||||
|
||||
private void mutate(UUID player, Runnable change) {
|
||||
changing.add(player);
|
||||
ownership.applying(player, true);
|
||||
try { change.run(); }
|
||||
finally { changing.remove(player); ownership.applying(player, false); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class IgnisCommands implements CommandExecutor, TabCompleter {
|
||||
private record Confirmation(UUID player, Set<Track> tracks, long expiresNanos) { }
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final EchoGameplay gameplay;
|
||||
private final InvisibilityProgress maskTracker;
|
||||
private final PearlProgress pearlTracker;
|
||||
private final DarknessProgress heartTracker;
|
||||
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, states, gameplay, maskTracker, null);
|
||||
}
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay,
|
||||
InvisibilityProgress maskTracker, PearlProgress pearlTracker) {
|
||||
this(plugin, states, gameplay, maskTracker, pearlTracker, null);
|
||||
}
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay,
|
||||
InvisibilityProgress maskTracker, PearlProgress pearlTracker, DarknessProgress heartTracker) {
|
||||
this.plugin = plugin;
|
||||
this.states = states;
|
||||
this.gameplay = gameplay;
|
||||
this.maskTracker = maskTracker;
|
||||
this.pearlTracker = pearlTracker;
|
||||
this.heartTracker = heartTracker;
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (command.getName().equals("ignis")) {
|
||||
if (!(sender instanceof org.bukkit.entity.Player player)) sender.sendMessage("This command is player-only.");
|
||||
else if (args.length == 1 && args[0].equalsIgnoreCase("umbra")) {
|
||||
var snapshot = states.snapshot();
|
||||
var progress = snapshot.players().getOrDefault(player.getUniqueId(), PlayerState.empty());
|
||||
sender.sendMessage(String.format(Locale.ROOT,
|
||||
"Invisibility %.2f/%.2f hours (%s); Pearls %d/%d (%s); Darkness %.2f/%.2f hours (%s).",
|
||||
progress.track(Track.MASK).progress() / 3_600_000.0, snapshot.limits().invisibilityMillis() / 3_600_000.0,
|
||||
status(progress, Track.MASK), progress.track(Track.DAGGER).progress(), snapshot.limits().pearls(),
|
||||
status(progress, Track.DAGGER), progress.track(Track.HEART).progress() / 3_600_000.0,
|
||||
snapshot.limits().darknessMillis() / 3_600_000.0, status(progress, Track.HEART)));
|
||||
} else sender.sendMessage("Use /ignis umbra to privately inspect your progress.");
|
||||
return true;
|
||||
}
|
||||
if (!sender.hasPermission("ignis.admin")) {
|
||||
sender.sendMessage("You do not have permission to administer Ignis.");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("inspect")) {
|
||||
UUID id = resolve(args[1]);
|
||||
var player = states.snapshot().players().getOrDefault(id, PlayerState.empty());
|
||||
String progress = java.util.Arrays.stream(Track.values())
|
||||
.map(track -> track + "=" + player.track(track).progress())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
sender.sendMessage(id + ": saved progress (MASK/HEART milliseconds, DAGGER pearls): " + progress
|
||||
+ "; pending=" + player.pendingRewards().size() + "; issued=" + player.issuedRewards().size());
|
||||
sender.sendMessage("Recipe unlocks: mask=" + status(player, Track.MASK)
|
||||
+ ", dagger=" + status(player, Track.DAGGER) + ", heart=" + status(player, Track.HEART));
|
||||
sender.sendMessage("Echo: eligibility=" + status(player, Track.VOID) + "; advancement-name scrambling="
|
||||
+ (player.track(Track.VOID).activated() ? "active" : "inactive"));
|
||||
sender.sendMessage("Void challenge: " + gameplay.trialSnapshot(id)
|
||||
.map(attempt -> (attempt.escaped() ? "awaiting-end-stone" : "awaiting-elytra-ascent")
|
||||
+ "; world=" + attempt.world() + "; damage-y=" + attempt.damageHeight())
|
||||
.orElse("none"));
|
||||
} else if (args.length == 1 && args[0].equalsIgnoreCase("limits")) {
|
||||
sender.sendMessage(states.snapshot().limits().toString());
|
||||
} else if (args.length == 4 && args[0].equalsIgnoreCase("limits") && args[1].equalsIgnoreCase("set")) {
|
||||
long value = Long.parseLong(args[3]);
|
||||
String setting = args[2].toLowerCase(Locale.ROOT);
|
||||
if (!Set.of("mask-ms", "pearls", "darkness-ms").contains(setting)) throw new IllegalArgumentException("Unknown limit.");
|
||||
mutation(sender, "limits " + setting + "=" + value, states.update(state -> {
|
||||
var limits = state.limits();
|
||||
return state.evaluate(new ProgressLimits(setting.equals("mask-ms") ? value : limits.invisibilityMillis(),
|
||||
setting.equals("pearls") ? value : limits.pearls(),
|
||||
setting.equals("darkness-ms") ? value : limits.darknessMillis()));
|
||||
}));
|
||||
} else if (args.length == 5 && args[0].equalsIgnoreCase("progress")
|
||||
&& (args[1].equalsIgnoreCase("set") || args[1].equalsIgnoreCase("add"))) {
|
||||
UUID id = resolve(args[2]);
|
||||
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) : track == Track.DAGGER && pearlTracker != null
|
||||
? pearlTracker.checkpoint(id) : CompletableFuture.completedFuture(states.snapshot());
|
||||
mutation(sender, "progress " + id + " " + track + " " + args[1] + " " + value,
|
||||
track == Track.HEART && heartTracker != null ? heartTracker.edit(id, value, add)
|
||||
: checkpoint.thenCompose(ignored -> states.update(state -> ProgressAdministration.progress(state, id, track, value, add))));
|
||||
} else if (args.length == 3 && args[0].equalsIgnoreCase("give") && Set.of("mask", "dagger", "heart").contains(args[2].toLowerCase(Locale.ROOT))) {
|
||||
UUID id = resolve(args[1]), receipt = UUID.randomUUID();
|
||||
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();
|
||||
mutation(sender, "grant Echo " + id, states.update(state -> EchoRewards.award(state, id, receipt)));
|
||||
} else if (args.length >= 3 && args[0].equalsIgnoreCase("reset")) {
|
||||
UUID id = resolve(args[1]);
|
||||
Set<Track> selected = args[2].equalsIgnoreCase("all") ? Set.of(Track.values())
|
||||
: Set.of(Track.valueOf(args[2].toUpperCase(Locale.ROOT)));
|
||||
long now = System.nanoTime();
|
||||
confirmations.values().removeIf(value -> now - value.expiresNanos() >= 0);
|
||||
if (args.length == 3) {
|
||||
confirmations.put(sender, new Confirmation(id, selected, now + 30_000_000_000L));
|
||||
sender.sendMessage("Repeat /ignisadmin reset " + id + " " + args[2] + " confirm within 30 seconds.");
|
||||
} else if (args.length == 4 && args[3].equalsIgnoreCase("confirm")) {
|
||||
Confirmation pending = confirmations.remove(sender);
|
||||
if (pending == null || !pending.player().equals(id) || !pending.tracks().equals(selected)) {
|
||||
throw new IllegalArgumentException("Request this reset before confirming it.");
|
||||
}
|
||||
if (selected.contains(Track.VOID)) gameplay.cancel(id);
|
||||
mutation(sender, "reset " + id + " " + selected,
|
||||
states.update(state -> ProgressAdministration.reset(state, id, selected)));
|
||||
} 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|dagger|heart>; reset <player|UUID> <track|all> [confirm].");
|
||||
} catch (IllegalArgumentException | ArithmeticException exception) {
|
||||
sender.sendMessage("Invalid Ignis command: " + exception.getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private UUID resolve(String value) {
|
||||
try {
|
||||
UUID id = UUID.fromString(value);
|
||||
if (states.snapshot().players().containsKey(id)) return id;
|
||||
} catch (IllegalArgumentException ignored) { /* Try an online name without network lookups. */ }
|
||||
var player = plugin.getServer().getPlayerExact(value);
|
||||
if (player != null) return player.getUniqueId();
|
||||
throw new IllegalArgumentException("Use an online player name or a known player's UUID.");
|
||||
}
|
||||
|
||||
private void mutation(CommandSender sender, String description, CompletableFuture<IgnisState> result) {
|
||||
String actor = sender.getName();
|
||||
result.whenComplete((state, error) -> {
|
||||
plugin.getLogger().info("Ignis admin " + actor + ": " + description + (error == null ? " succeeded" : " failed"));
|
||||
gameplay.onServer(() -> {
|
||||
sender.sendMessage(error == null ? "Ignis change saved." : "Ignis change failed; previous state retained.");
|
||||
if (error == null) gameplay.retryDeliveries();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static String status(PlayerState state, Track track) {
|
||||
return state.track(track).unlocked() ? "unlocked" : "locked";
|
||||
}
|
||||
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (args.length == 0) return List.of();
|
||||
if (command.getName().equals("ignis")) return sender instanceof org.bukkit.entity.Player && args.length == 1
|
||||
? matching(List.of("umbra"), args[0]) : List.of();
|
||||
if (!sender.hasPermission("ignis.admin")) return List.of();
|
||||
String sub = args[0].toLowerCase(Locale.ROOT);
|
||||
List<String> options = List.of();
|
||||
if (args.length == 1) options = List.of("inspect", "limits", "progress", "complete", "give", "reset");
|
||||
else if (args.length == 2 && Set.of("inspect", "complete", "give", "reset").contains(sub)
|
||||
|| args.length == 3 && sub.equals("progress")) {
|
||||
var players = new java.util.ArrayList<String>();
|
||||
states.snapshot().players().keySet().forEach(id -> players.add(id.toString()));
|
||||
plugin.getServer().getOnlinePlayers().forEach(player -> players.add(player.getName()));
|
||||
options = players;
|
||||
} else if (sub.equals("limits")) {
|
||||
if (args.length == 2) options = List.of("set");
|
||||
else if (args.length == 3) options = List.of("mask-ms", "pearls", "darkness-ms");
|
||||
else if (args.length == 4) options = switch (args[2].toLowerCase(Locale.ROOT)) {
|
||||
case "mask-ms" -> List.of("43200000");
|
||||
case "pearls" -> List.of("313");
|
||||
case "darkness-ms" -> List.of("21600000");
|
||||
default -> List.of();
|
||||
};
|
||||
} else if (sub.equals("progress")) {
|
||||
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", "dagger", "heart");
|
||||
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");
|
||||
else if (args.length == 4) options = List.of("confirm");
|
||||
}
|
||||
return matching(options, args[args.length - 1]);
|
||||
}
|
||||
|
||||
private static List<String> matching(List<String> options, String prefix) {
|
||||
return options.stream().filter(value -> value.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT)))
|
||||
.distinct().sorted().toList();
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public record IgnisState(ProgressLimits limits, Map<UUID, PlayerState> players)
|
||||
previous.activated(), previous.generation()));
|
||||
}
|
||||
}
|
||||
evaluated.put(id, new PlayerState(tracks, player.pendingRewards()));
|
||||
evaluated.put(id, new PlayerState(tracks, player.pendingRewards(), player.issuedRewards()));
|
||||
});
|
||||
return new IgnisState(nextLimits, evaluated);
|
||||
}
|
||||
|
||||
@@ -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,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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 final Set<MaskToken> offered = 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)) 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);
|
||||
if (error == null) offered.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,26 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 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) {
|
||||
return REWARDS.issue(state, issuer, receipt, adminGrant);
|
||||
}
|
||||
public static boolean valid(IgnisState state, UUID holder, MaskToken token) {
|
||||
return REWARDS.valid(state, holder, convert(token));
|
||||
}
|
||||
public static Optional<MaskToken> craftingToken(IgnisState state, UUID issuer) {
|
||||
return REWARDS.craftingToken(state, issuer).map(token -> new MaskToken(token.issuer(), token.receipt(), token.generation()));
|
||||
}
|
||||
public static IgnisState acknowledge(IgnisState state, MaskToken token) {
|
||||
return REWARDS.acknowledge(state, convert(token));
|
||||
}
|
||||
private static ItemToken convert(MaskToken token) {
|
||||
return new ItemToken(token.issuer(), token.receipt(), token.generation());
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,20 @@ package games.dmg.spigotignis;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Pending reward identifiers are durable; gameplay delivery is a later adapter. */
|
||||
public record PlayerState(Map<Track, TrackState> tracks, Map<UUID, Track> pendingRewards) {
|
||||
/** Pending delivery and issued-item receipts are separate to permit retry without granting twice. */
|
||||
public record PlayerState(Map<Track, TrackState> tracks, Map<UUID, Track> pendingRewards,
|
||||
Map<UUID, Track> issuedRewards) {
|
||||
public PlayerState {
|
||||
tracks = Map.copyOf(tracks);
|
||||
pendingRewards = Map.copyOf(pendingRewards);
|
||||
issuedRewards = Map.copyOf(issuedRewards);
|
||||
if (!issuedRewards.entrySet().containsAll(pendingRewards.entrySet())) {
|
||||
throw new IllegalArgumentException("Pending rewards require matching issuance receipts.");
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerState(Map<Track, TrackState> tracks, Map<UUID, Track> pendingRewards) {
|
||||
this(tracks, pendingRewards, pendingRewards);
|
||||
}
|
||||
|
||||
public static PlayerState empty() { return new PlayerState(Map.of(), Map.of()); }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class ProgressAdministration {
|
||||
private ProgressAdministration() { }
|
||||
public static IgnisState progress(IgnisState state, UUID player, Track track, long value, boolean add) {
|
||||
if (value < 0 || track == Track.VOID) throw new IllegalArgumentException("Use nonnegative progress or complete void.");
|
||||
var previous = state.players().getOrDefault(player, PlayerState.empty());
|
||||
TrackState current = previous.track(track);
|
||||
long updated = add ? Math.addExact(current.progress(), value) : value;
|
||||
var tracks = new HashMap<>(previous.tracks());
|
||||
tracks.put(track, new TrackState(updated, current.unlocked(), current.activated(), current.generation()));
|
||||
var players = new HashMap<>(state.players());
|
||||
players.put(player, new PlayerState(tracks, previous.pendingRewards(), previous.issuedRewards()));
|
||||
return new IgnisState(state.limits(), players).evaluate(state.limits());
|
||||
}
|
||||
|
||||
public static IgnisState reset(IgnisState state, UUID playerId, Set<Track> selected) {
|
||||
PlayerState player = state.players().getOrDefault(playerId, PlayerState.empty());
|
||||
var tracks = new HashMap<>(player.tracks());
|
||||
for (Track track : selected) {
|
||||
tracks.put(track, new TrackState(0, false, false, Math.addExact(player.track(track).generation(), 1)));
|
||||
}
|
||||
var pending = new HashMap<>(player.pendingRewards());
|
||||
var issued = new HashMap<>(player.issuedRewards());
|
||||
pending.entrySet().removeIf(entry -> selected.contains(entry.getValue()));
|
||||
issued.entrySet().removeIf(entry -> selected.contains(entry.getValue()));
|
||||
var players = new HashMap<>(state.players());
|
||||
players.put(playerId, new PlayerState(tracks, pending, issued));
|
||||
return new IgnisState(state.limits(), players);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Keyed;
|
||||
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.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Shared preissued-receipt crafting and delivery; vanilla owns ingredient consumption. */
|
||||
public abstract class ReceiptCrafting implements Listener, Runnable {
|
||||
private final JavaPlugin plugin;
|
||||
private final StateService states;
|
||||
private final ReceiptItems items;
|
||||
private final EchoGameplay dispatch;
|
||||
private final Track track;
|
||||
private final NamespacedKey key;
|
||||
private final TradableRewards rewards;
|
||||
private final Function<ItemStack, ShapedRecipe> recipeFactory;
|
||||
private boolean registered;
|
||||
private final Set<UUID> preparing = new HashSet<>();
|
||||
private final Set<ItemToken> delivering = new HashSet<>();
|
||||
private final Set<ItemToken> offered = new HashSet<>();
|
||||
|
||||
protected ReceiptCrafting(JavaPlugin plugin, StateService states, ReceiptItems items, EchoGameplay dispatch,
|
||||
Track track, NamespacedKey key, Function<ItemStack, ShapedRecipe> recipeFactory) {
|
||||
this.plugin = plugin; this.states = states; this.items = items; this.dispatch = dispatch;
|
||||
this.track = track; this.key = key; this.recipeFactory = recipeFactory;
|
||||
rewards = new TradableRewards(track);
|
||||
}
|
||||
protected abstract void configure(ShapedRecipe recipe);
|
||||
protected boolean accepts(ItemStack[] matrix) { return true; }
|
||||
|
||||
@Override public void run() {
|
||||
if (!registered) {
|
||||
var recipe = recipeFactory.apply(items.prototype());
|
||||
configure(recipe);
|
||||
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).unlocked()) {
|
||||
if (player.hasDiscoveredRecipe(key)) player.undiscoverRecipe(key);
|
||||
continue;
|
||||
}
|
||||
ensureReceipt(player);
|
||||
if (!player.hasDiscoveredRecipe(key)) player.discoverRecipe(key);
|
||||
for (var entry : state.pendingRewards().entrySet()) {
|
||||
if (entry.getValue() != track) continue;
|
||||
var token = new ItemToken(player.getUniqueId(), entry.getKey(), state.track(track).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(Player player) {
|
||||
var owner = player.getUniqueId();
|
||||
var snapshot = states.snapshot();
|
||||
var state = snapshot.players().getOrDefault(owner, PlayerState.empty()).track(track);
|
||||
if (!state.unlocked() || rewards.craftingToken(snapshot, owner).isPresent() || !preparing.add(owner)) return;
|
||||
var receipt = UUID.randomUUID();
|
||||
long generation = state.generation();
|
||||
states.update(current -> {
|
||||
var now = current.players().getOrDefault(owner, PlayerState.empty()).track(track);
|
||||
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 CraftingInventory inventory && matches(inventory.getRecipe())) {
|
||||
inventory.setResult(items.prototype());
|
||||
plugin.getServer().getPluginManager().callEvent(new PrepareItemCraftEvent(inventory, view, false));
|
||||
online.updateInventory();
|
||||
}
|
||||
}));
|
||||
}
|
||||
public void close() { if (registered) plugin.getServer().removeRecipe(key); }
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (!matches(event.getRecipe())) return;
|
||||
var identity = items.identify(event.getInventory().getResult());
|
||||
if (!accepts(event.getInventory().getMatrix()) || !(event.getWhoClicked() instanceof 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);
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCrafter(CrafterCraftEvent event) { if (matches(event.getRecipe())) event.setCancelled(true); }
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onPrepare(PrepareItemCraftEvent event) {
|
||||
if (!matches(event.getInventory().getRecipe()) || event.getInventory().getResult() == null) return;
|
||||
if (!accepts(event.getInventory().getMatrix()) || !(event.getView().getPlayer() instanceof 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);
|
||||
}
|
||||
private boolean matches(Recipe recipe) { return recipe instanceof Keyed keyed && keyed.getKey().equals(key); }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
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.persistence.PersistentDataType;
|
||||
|
||||
/** Plain-item receipt encoding and delivery; appearance and track identity are fixed by adapters. */
|
||||
public class ReceiptItems {
|
||||
private final Material material;
|
||||
private final NamespacedKey identity;
|
||||
private final String title;
|
||||
private final Supplier<ItemStack> factory;
|
||||
protected ReceiptItems(Material material, String key, String title, Supplier<ItemStack> factory) {
|
||||
this.material = material;
|
||||
this.identity = new NamespacedKey("spigotignis", key);
|
||||
this.title = title;
|
||||
this.factory = factory;
|
||||
}
|
||||
public ItemStack prototype() {
|
||||
var item = factory.get();
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(title, NamedTextColor.DARK_PURPLE));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
public ItemStack create(ItemToken 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 boolean offer(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() != material || !item.hasItemMeta()) return Optional.empty();
|
||||
return EchoIdentity.decode(item.getItemMeta().getPersistentDataContainer().get(identity, PersistentDataType.STRING))
|
||||
.map(token -> new ItemToken(token.owner(), token.receipt(), token.generation()));
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,126 @@ package games.dmg.spigotignis;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Foundation bootstrap only: no gameplay listeners or player/admin commands are registered. */
|
||||
/** 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;
|
||||
private PearlProgress pearlProgress;
|
||||
private DaggerCrafting daggerCrafting;
|
||||
private DarknessProgress heartProgress;
|
||||
private HeartCrafting heartCrafting;
|
||||
private HeartEffects heartEffects;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean heartSaveWarning = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean pearlSaveWarning = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
initialization = StateService.open(new FileStateRepository(getDataFolder().toPath().resolve("state.bin")));
|
||||
initialization = StateService.open(new FileStateRepository(getDataFolder().toPath().resolve("state.bin")))
|
||||
.thenCompose(HeartProgressState::prepare);
|
||||
initialization.whenComplete((service, error) -> {
|
||||
if (error != null) {
|
||||
getLogger().severe("Ignis state unavailable; original data retained. Restore a verified backup before retrying.");
|
||||
getLogger().severe("Ignis state initialization failed; original data retained. Check storage and validated recovery guidance before retrying.");
|
||||
} else {
|
||||
getLogger().info("Ignis foundation loaded. No gameplay abilities or commands are enabled.");
|
||||
if (!isEnabled()) { service.close(); return; }
|
||||
try {
|
||||
getServer().getScheduler().runTask(this, () -> {
|
||||
if (!isEnabled()) { service.close(); return; }
|
||||
var gameplay = new EchoGameplay(this, service, new EchoItems());
|
||||
getServer().getPluginManager().registerEvents(gameplay, this);
|
||||
getServer().getPluginManager().registerEvents(new AnnouncementListener(gameplay::activated), this);
|
||||
getServer().getScheduler().runTaskTimer(this, gameplay::retryDeliveries, 1L, 100L);
|
||||
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);
|
||||
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);
|
||||
heartProgress = new DarknessProgress(service, System::nanoTime);
|
||||
var heartListener = new HeartProgressListener(this, heartProgress, this::reportHeartSave);
|
||||
var heartItems = new HeartItems();
|
||||
heartCrafting = new HeartCrafting(this, service, heartItems, gameplay);
|
||||
heartEffects = new HeartEffects(this, service, heartItems);
|
||||
getServer().getPluginManager().registerEvents(heartListener, this);
|
||||
getServer().getPluginManager().registerEvents(heartCrafting, this);
|
||||
getServer().getPluginManager().registerEvents(heartEffects, this);
|
||||
getServer().getPluginManager().registerEvents(new HeartPlacement(heartItems), this);
|
||||
getServer().getScheduler().runTaskTimer(this, heartListener, 1L, 1L);
|
||||
getServer().getScheduler().runTaskTimer(this, heartListener::checkpoint, 100L, 100L);
|
||||
getServer().getScheduler().runTaskTimer(this, heartCrafting, 1L, 20L);
|
||||
getServer().getScheduler().runTaskTimer(this, heartEffects, 1L, 1L);
|
||||
var commands = new IgnisCommands(this, service, gameplay, maskTracker, pearlProgress, heartProgress);
|
||||
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, Mask, Dagger and Heart enabled.");
|
||||
});
|
||||
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
|
||||
service.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (initialization != null) initialization.thenAccept(StateService::close);
|
||||
try {
|
||||
if (heartEffects != null) heartEffects.close();
|
||||
if (heartCrafting != null) heartCrafting.close();
|
||||
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 pearlDrain = (pearlProgress == null ? CompletableFuture.<Void>completedFuture(null) : pearlProgress.checkpointAll())
|
||||
.whenComplete((saved, failure) -> reportPearlSave(failure));
|
||||
var heartDrain = (heartProgress == null ? CompletableFuture.<Void>completedFuture(null) : heartProgress.stopAll())
|
||||
.whenComplete((saved, failure) -> reportHeartSave(failure));
|
||||
CompletableFuture.allOf(pearlDrain, heartDrain).whenComplete((saved, failure) -> {
|
||||
if (initialization != null) initialization.thenAccept(StateService::close);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void reportHeartSave(Throwable failure) {
|
||||
if (failure == null) heartSaveWarning.set(false);
|
||||
else if (heartSaveWarning.compareAndSet(false, true)) {
|
||||
getLogger().warning("Heart progress save failed; desired state remains in memory for retry while enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Server-thread transient attempts. Never persisted across disconnect/restart. */
|
||||
public final class VoidTrial {
|
||||
public record Snapshot(UUID world, double damageHeight, boolean escaped) { }
|
||||
private record Attempt(UUID world, double damageHeight, boolean escaped) { }
|
||||
private final Map<UUID, Attempt> attempts = new HashMap<>();
|
||||
|
||||
public void damaged(UUID player, UUID world, double height) {
|
||||
if (Double.isFinite(height)) attempts.put(player, new Attempt(world, height, false));
|
||||
}
|
||||
|
||||
public boolean moved(UUID player, UUID world, double height, boolean gliding,
|
||||
boolean grounded, boolean endStone) {
|
||||
Attempt attempt = attempts.get(player);
|
||||
if (attempt == null) return false;
|
||||
if (!attempt.world().equals(world) || !Double.isFinite(height)) {
|
||||
cancel(player);
|
||||
return false;
|
||||
}
|
||||
boolean escaped = attempt.escaped() || gliding && height > attempt.damageHeight();
|
||||
if (grounded) {
|
||||
cancel(player);
|
||||
return escaped && endStone;
|
||||
}
|
||||
attempts.put(player, new Attempt(world, attempt.damageHeight(), escaped));
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Server-thread read only: never advances, cancels or persists an attempt. */
|
||||
public java.util.Optional<Snapshot> snapshot(UUID player) {
|
||||
Attempt attempt = attempts.get(player);
|
||||
return attempt == null ? java.util.Optional.empty()
|
||||
: java.util.Optional.of(new Snapshot(attempt.world(), attempt.damageHeight(), attempt.escaped()));
|
||||
}
|
||||
|
||||
public void cancel(UUID player) { attempts.remove(player); }
|
||||
}
|
||||
@@ -2,4 +2,17 @@ name: SpigotIgnis
|
||||
version: '${version}'
|
||||
main: games.dmg.spigotignis.SpigotIgnisPlugin
|
||||
api-version: '1.21'
|
||||
description: Ignis progression foundation; gameplay is not implemented yet.
|
||||
description: Earnable shadow progression with Echo of the Abyss, The Mask, and The Dagger.
|
||||
depend: [ProtocolLib]
|
||||
commands:
|
||||
ignis:
|
||||
description: Inspect private shadow progression.
|
||||
usage: /ignis umbra
|
||||
ignisadmin:
|
||||
description: Administer Ignis progression and unlocked rewards.
|
||||
usage: /ignisadmin
|
||||
permission: ignis.admin
|
||||
permissions:
|
||||
ignis.admin:
|
||||
description: Inspect, configure, grant, and reset Ignis progression.
|
||||
default: op
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.TranslatableComponent;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdvancementMaskerTest {
|
||||
@Test
|
||||
void taskGoalChallengeClearInheritedIdentityActionsWithoutTouchingOtherMessages() {
|
||||
for (String type : java.util.List.of("task", "goal", "challenge")) {
|
||||
var achievement = Component.text("Achievement").hoverEvent(Component.text("Keep this description"));
|
||||
var announcement = Component.translatable("chat.type.advancement." + type,
|
||||
Component.text("AlreadyMasked").decorate(TextDecoration.OBFUSCATED), achievement);
|
||||
var wrapper = Component.empty().clickEvent(ClickEvent.suggestCommand("/msg RealPlayer"))
|
||||
.hoverEvent(Component.text("RealPlayer")).append(announcement);
|
||||
var result = AdvancementMasker.mask(wrapper);
|
||||
assertNull(result.clickEvent());
|
||||
assertNull(result.hoverEvent());
|
||||
var translated = (TranslatableComponent) result.children().getFirst();
|
||||
assertEquals(achievement, translated.arguments().get(1).value());
|
||||
assertEquals(result, AdvancementMasker.mask(result));
|
||||
}
|
||||
var chat = Component.translatable("chat.type.text", Component.text("RealPlayer"), Component.text("message"));
|
||||
assertSame(chat, AdvancementMasker.mask(chat));
|
||||
var custom = Component.text("RealPlayer completed a custom achievement");
|
||||
assertSame(custom, AdvancementMasker.mask(custom));
|
||||
assertNull(AdvancementMasker.mask(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacesOnlyAdvancementNameAndPreservesAdvancementDetails() {
|
||||
var name = Component.text("RealPlayer").clickEvent(ClickEvent.suggestCommand("/msg RealPlayer "))
|
||||
.hoverEvent(Component.text("RealPlayer identity")).insertion("RealPlayer");
|
||||
var advancement = Component.text("RealPlayer's Challenge").hoverEvent(Component.text("Readable description"));
|
||||
var original = Component.translatable("chat.type.advancement.challenge", name, advancement);
|
||||
var masked = (TranslatableComponent) AdvancementMasker.mask(original);
|
||||
var alias = (Component) masked.arguments().getFirst().value();
|
||||
assertEquals(TextDecoration.State.TRUE, alias.decoration(TextDecoration.OBFUSCATED));
|
||||
assertNull(alias.clickEvent());
|
||||
assertNull(alias.hoverEvent());
|
||||
assertEquals("", alias.insertion());
|
||||
assertEquals(advancement, masked.arguments().get(1).value());
|
||||
assertEquals(name, original.arguments().getFirst().value());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerAdvancementDoneEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AnnouncementListenerTest {
|
||||
@Test
|
||||
void onlyActivatedPlayersExistingAnnouncementsAreChanged() {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var event = mock(PlayerAdvancementDoneEvent.class);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
var original = Component.translatable("chat.type.advancement.task", Component.text("Name"), Component.text("Title"));
|
||||
when(event.message()).thenReturn(original);
|
||||
new AnnouncementListener(ignored -> false).onAdvancement(event);
|
||||
verify(event, never()).message(any(Component.class));
|
||||
new AnnouncementListener(id::equals).onAdvancement(event);
|
||||
verify(event).message(AdvancementMasker.mask(original));
|
||||
clearInvocations(event);
|
||||
when(event.message()).thenReturn(null);
|
||||
new AnnouncementListener(id::equals).onAdvancement(event);
|
||||
verify(event, never()).message(any(Component.class));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 DarknessAdministrationTest {
|
||||
@TempDir Path directory;
|
||||
@Test
|
||||
void editsFlushElapsedProgressAndRebaseRatherThanReplayingTheOldInterval() throws Exception {
|
||||
for (boolean add : new boolean[]{false, true}) {
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve(add + ".bin"))).get()) {
|
||||
var clock = new AtomicLong();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
var id = UUID.randomUUID();
|
||||
tracker.observe(id, true);
|
||||
clock.set(100_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.edit(id, 500, add).get();
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(add ? 600 : 500, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
tracker.interrupt(id);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DarknessBatchTest {
|
||||
@Test
|
||||
void periodicCallsCoalesceButShutdownQueuesFreshInterruptionsBehindAnInflightSave() throws Exception {
|
||||
var started = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var writes = new AtomicInteger();
|
||||
var repository = new StateRepository() {
|
||||
@Override public IgnisState load() { return IgnisState.empty(); }
|
||||
@Override public void save(IgnisState state) throws java.io.IOException {
|
||||
if (writes.incrementAndGet() == 1) {
|
||||
started.countDown();
|
||||
try {
|
||||
if (!release.await(3, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("fixture timeout");
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
try (var states = StateService.open(repository).get()) {
|
||||
var clock = new AtomicLong();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
var id = UUID.randomUUID();
|
||||
tracker.observe(id, true);
|
||||
clock.set(100_000_000L);
|
||||
tracker.observe(id, true);
|
||||
var first = tracker.checkpointAll();
|
||||
try {
|
||||
assertTrue(started.await(3, java.util.concurrent.TimeUnit.SECONDS));
|
||||
clock.set(200_000_000L);
|
||||
tracker.observe(id, true);
|
||||
assertSame(first, tracker.checkpointAll());
|
||||
var stopping = tracker.stopAll();
|
||||
assertNotSame(first, stopping);
|
||||
release.countDown();
|
||||
stopping.get();
|
||||
assertEquals(2, writes.get());
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkpointsAndShutdownUseOneWriteForAllRememberedPlayers() throws Exception {
|
||||
var writes = new AtomicInteger();
|
||||
var repository = new StateRepository() {
|
||||
@Override public IgnisState load() { return IgnisState.empty(); }
|
||||
@Override public void save(IgnisState state) { writes.incrementAndGet(); }
|
||||
};
|
||||
try (var states = StateService.open(repository).get()) {
|
||||
var clock = new AtomicLong();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
var players = java.util.stream.IntStream.range(0, 20).mapToObj(ignored -> UUID.randomUUID()).toList();
|
||||
players.forEach(id -> tracker.observe(id, true));
|
||||
clock.set(100_000_000L);
|
||||
players.forEach(id -> tracker.observe(id, true));
|
||||
tracker.checkpointAll().get();
|
||||
assertEquals(1, writes.get());
|
||||
for (var id : players) assertEquals(100, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
tracker.stopAll().get();
|
||||
assertEquals(2, writes.get());
|
||||
for (var id : players) assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 DarknessProgressTest {
|
||||
@TempDir Path directory;
|
||||
@Test
|
||||
void shutdownClearsUnfinishedStreakAndFailedResetRemainsRetryable() throws Exception {
|
||||
var disk = new FileStateRepository(directory.resolve("shutdown.bin"));
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
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 storage failure");
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
var clock = new AtomicLong();
|
||||
var id = UUID.randomUUID();
|
||||
try (var states = StateService.open(boundary).get()) {
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
tracker.observe(id, true);
|
||||
clock.set(500_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
fail.set(true);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> tracker.stopAll().get());
|
||||
assertEquals(500, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
fail.set(false);
|
||||
tracker.checkpointAll().get();
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
clock.set(10_000_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpointAll().get();
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void queuedOldIntervalCannotRestoreProgressAfterInterruptionOrAdministratorReset() throws Exception {
|
||||
for (boolean adminReset : new boolean[]{false, true}) {
|
||||
var clock = new AtomicLong();
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var id = UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve(adminReset + ".bin"))).get()) {
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
tracker.observe(id, true);
|
||||
clock.set(500_000_000L);
|
||||
tracker.observe(id, true);
|
||||
var blocker = states.update(state -> {
|
||||
entered.countDown();
|
||||
try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Timed out"); }
|
||||
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IllegalStateException(error); }
|
||||
return state;
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS));
|
||||
if (adminReset) states.update(state -> ProgressAdministration.reset(state, id, java.util.Set.of(Track.HEART)));
|
||||
var stale = tracker.checkpoint(id);
|
||||
if (!adminReset) tracker.interrupt(id);
|
||||
release.countDown();
|
||||
blocker.get(); stale.get();
|
||||
assertEquals(0, states.snapshot().players().getOrDefault(id, PlayerState.empty()).track(Track.HEART).progress());
|
||||
tracker.observe(id, true);
|
||||
clock.addAndGet(200_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(200, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesContinuousProgressResetsInterruptionsAndRetainsCompletedUnlock() throws Exception {
|
||||
var clock = new AtomicLong();
|
||||
UUID id = UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
states.configure(1000, 313, 1000).get();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
tracker.observe(id, true);
|
||||
clock.set(500_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(500, states.snapshot().players().getOrDefault(id, PlayerState.empty()).track(Track.HEART).progress());
|
||||
tracker.interrupt(id);
|
||||
tracker.checkpoint(id).get();
|
||||
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
tracker.observe(id, true);
|
||||
clock.addAndGet(1_000_000_000L);
|
||||
tracker.observe(id, true);
|
||||
tracker.checkpoint(id).get();
|
||||
assertTrue(states.snapshot().players().get(id).track(Track.HEART).unlocked());
|
||||
tracker.interrupt(id);
|
||||
tracker.checkpoint(id).get();
|
||||
assertTrue(states.snapshot().players().get(id).track(Track.HEART).unlocked());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DarknessStreakTest {
|
||||
@Test
|
||||
void administratorSeedCountsOnlyUntilAnUnfinishedStreakIsInterrupted() {
|
||||
var time = new AtomicLong();
|
||||
var streak = new DarknessStreak(time::get, 500);
|
||||
assertEquals(500, streak.sample(true, 1000).millis());
|
||||
time.set(200_000_000L);
|
||||
assertEquals(700, streak.sample(true, 1000).millis());
|
||||
assertEquals(0, streak.interrupt().millis());
|
||||
time.set(900_000_000L);
|
||||
assertEquals(0, streak.sample(true, 1000).millis());
|
||||
assertThrows(IllegalArgumentException.class, () -> new DarknessStreak(time::get, -1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresOneUninterruptedSixHourStreakAndRetainsCompletedUnlock() {
|
||||
long hour = 3_600_000;
|
||||
var time = new AtomicLong();
|
||||
var streak = new DarknessStreak(time::get);
|
||||
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(true, 6 * hour));
|
||||
time.set(3 * hour * 1_000_000);
|
||||
assertEquals(new DarknessStreak.Progress(3 * hour, false), streak.sample(true, 6 * hour));
|
||||
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(false, 6 * hour));
|
||||
time.set(10 * hour * 1_000_000); // Time outside qualifying darkness earns nothing.
|
||||
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(true, 6 * hour));
|
||||
time.set(16 * hour * 1_000_000);
|
||||
assertEquals(new DarknessStreak.Progress(6 * hour, true), streak.sample(true, 6 * hour));
|
||||
assertEquals(new DarknessStreak.Progress(6 * hour, true), streak.interrupt());
|
||||
assertTrue(streak.sample(false, 12 * hour).complete(), "Higher thresholds do not revoke completion");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Server;
|
||||
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.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class EchoGameplayTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test
|
||||
void ownerActivationCommitsOnceBeforeConsumingAndDeniedInteractionsDoNothing() throws Exception {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var token = new EchoToken(owner, receipt, 0);
|
||||
var items = mock(EchoItems.class);
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(server.getPlayer(owner)).thenReturn(player);
|
||||
var scheduler = mock(BukkitScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
var callbacks = new ConcurrentLinkedQueue<Runnable>();
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
|
||||
callbacks.add(call.getArgument(1)); return null;
|
||||
});
|
||||
var event = mock(PlayerInteractEvent.class);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
|
||||
when(event.useItemInHand()).thenReturn(Event.Result.DENY);
|
||||
when(items.identify(null)).thenReturn(Optional.of(token));
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get(3, TimeUnit.SECONDS)) {
|
||||
states.update(state -> EchoRewards.award(state, owner, receipt)).get(3, TimeUnit.SECONDS);
|
||||
var gameplay = new EchoGameplay(plugin, states, items);
|
||||
gameplay.onUse(event);
|
||||
assertFalse(gameplay.activated(owner));
|
||||
verify(items, never()).consumeOne(any(), any());
|
||||
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
|
||||
gameplay.onUse(event);
|
||||
gameplay.onUse(event);
|
||||
states.update(state -> state).get(3, TimeUnit.SECONDS);
|
||||
assertTrue(gameplay.activated(owner));
|
||||
verify(items, never()).consumeOne(any(), any());
|
||||
callbacks.forEach(Runnable::run);
|
||||
verify(items, times(1)).consumeOne(player, token);
|
||||
verify(event, times(2)).setCancelled(true);
|
||||
|
||||
// A later admin reset must supersede a delayed activation inventory callback.
|
||||
callbacks.clear();
|
||||
states.update(state -> ProgressAdministration.reset(state, owner, java.util.Set.of(Track.VOID))).get();
|
||||
UUID replacementId = UUID.randomUUID();
|
||||
var replacement = new EchoToken(owner, replacementId, 1);
|
||||
states.update(state -> EchoRewards.award(state, owner, replacementId)).get();
|
||||
when(items.identify(null)).thenReturn(Optional.of(replacement));
|
||||
gameplay.onUse(event);
|
||||
states.update(state -> ProgressAdministration.reset(state, owner, java.util.Set.of(Track.VOID))).get();
|
||||
callbacks.forEach(Runnable::run);
|
||||
verify(items, never()).consumeOne(player, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullInventoryLeavesRewardPendingAndRepeatedDeliveryDoesNotOfferTwice() throws Exception {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var token = new EchoToken(owner, receipt, 0);
|
||||
var items = mock(EchoItems.class);
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
var scheduler = mock(BukkitScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get(3, TimeUnit.SECONDS)) {
|
||||
states.update(state -> EchoRewards.award(state, owner, receipt)).get(3, TimeUnit.SECONDS);
|
||||
var gameplay = new EchoGameplay(plugin, states, items);
|
||||
gameplay.retryDeliveries();
|
||||
assertEquals(1, states.snapshot().players().get(owner).pendingRewards().size());
|
||||
when(items.offer(player, token)).thenReturn(true);
|
||||
gameplay.retryDeliveries();
|
||||
gameplay.retryDeliveries();
|
||||
states.update(state -> state).get(3, TimeUnit.SECONDS);
|
||||
assertTrue(states.snapshot().players().get(owner).pendingRewards().isEmpty());
|
||||
assertTrue(EchoRewards.valid(states.snapshot(), owner, token));
|
||||
verify(items, times(2)).offer(player, token); // one full-inventory attempt, one delivery
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EchoIdentityTest {
|
||||
@Test
|
||||
void authenticatedPayloadRoundTripsAndMalformedPayloadsAreIgnored() {
|
||||
var token = new EchoToken(UUID.randomUUID(), UUID.randomUUID(), 9);
|
||||
assertEquals(token, EchoIdentity.decode(EchoIdentity.encode(token)).orElse(null));
|
||||
assertTrue(EchoIdentity.decode(null).isEmpty());
|
||||
assertTrue(EchoIdentity.decode("Echo of the Abyss").isEmpty());
|
||||
assertTrue(EchoIdentity.decode(token.owner() + ":" + token.receipt() + ":-1").isEmpty());
|
||||
assertTrue(EchoIdentity.decode(EchoIdentity.encode(token) + ":extra").isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -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.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EchoItemsTest {
|
||||
@Test
|
||||
void displayNameAloneAndWrongMaterialNeverAuthenticate() {
|
||||
var items = new EchoItems();
|
||||
assertTrue(items.identify(null).isEmpty());
|
||||
var item = mock(ItemStack.class);
|
||||
var meta = mock(ItemMeta.class);
|
||||
var tags = mock(PersistentDataContainer.class);
|
||||
when(item.getType()).thenReturn(Material.ECHO_SHARD);
|
||||
when(item.hasItemMeta()).thenReturn(true);
|
||||
when(item.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getPersistentDataContainer()).thenReturn(tags);
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
var token = new EchoToken(UUID.randomUUID(), UUID.randomUUID(), 0);
|
||||
when(tags.get(any(), eq(PersistentDataType.STRING))).thenReturn(EchoIdentity.encode(token));
|
||||
assertEquals(token, items.identify(item).orElseThrow());
|
||||
when(item.getType()).thenReturn(Material.DIAMOND);
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EchoRewardsTest {
|
||||
@Test
|
||||
void foreignUnknownAndObsoleteReceiptsCannotActivateEvenWithMatchingLorePayload() {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var state = EchoRewards.award(IgnisState.empty(), owner, receipt);
|
||||
assertFalse(EchoRewards.valid(state, owner, new EchoToken(owner, UUID.randomUUID(), 0)));
|
||||
assertFalse(EchoRewards.valid(state, owner, new EchoToken(owner, receipt, 1)));
|
||||
assertFalse(EchoRewards.valid(state, UUID.randomUUID(), new EchoToken(owner, receipt, 0)));
|
||||
UUID replacement = UUID.randomUUID();
|
||||
var repeated = EchoRewards.award(state, owner, replacement);
|
||||
assertEquals(2, repeated.players().get(owner).pendingRewards().size());
|
||||
assertTrue(EchoRewards.valid(repeated, owner, new EchoToken(owner, replacement, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyOwnerCanActivateIssuedItemAndDeliveryAcknowledgementRetainsValidity() {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var token = new EchoToken(owner, receipt, 0);
|
||||
var awarded = EchoRewards.award(IgnisState.empty(), owner, receipt);
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> EchoRewards.activate(awarded, UUID.randomUUID(), token));
|
||||
var delivered = EchoRewards.acknowledge(awarded, token);
|
||||
assertTrue(delivered.players().get(owner).pendingRewards().isEmpty());
|
||||
var activated = EchoRewards.activate(delivered, owner, token);
|
||||
assertTrue(activated.players().get(owner).track(Track.VOID).activated());
|
||||
assertThrows(IllegalArgumentException.class, () -> EchoRewards.activate(activated, owner, token));
|
||||
assertEquals(activated, EchoRewards.award(activated, owner, UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionCreatesOnePendingBoundReceiptAndRetryDoesNotDuplicateIt() {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var awarded = EchoRewards.award(IgnisState.empty(), owner, receipt);
|
||||
assertEquals(Track.VOID, awarded.players().get(owner).pendingRewards().get(receipt));
|
||||
assertTrue(awarded.players().get(owner).track(Track.VOID).unlocked());
|
||||
assertFalse(awarded.players().get(owner).track(Track.VOID).activated());
|
||||
assertEquals(awarded, EchoRewards.award(awarded, owner, receipt));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,37 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
class FileStateRepositoryTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test
|
||||
void versionOnePendingRewardsMigrateToIssuedReceiptsWithoutDataLoss() throws Exception {
|
||||
Path path = directory.resolve("state.bin");
|
||||
UUID player = UUID.randomUUID(), reward = UUID.randomUUID();
|
||||
try (var out = new java.io.DataOutputStream(java.nio.file.Files.newOutputStream(path))) {
|
||||
out.writeInt(0x49474e49); out.writeInt(1);
|
||||
out.writeLong(120); out.writeLong(313); out.writeLong(60);
|
||||
out.writeInt(1);
|
||||
out.writeLong(player.getMostSignificantBits()); out.writeLong(player.getLeastSignificantBits());
|
||||
out.writeInt(0); out.writeInt(1);
|
||||
out.writeLong(reward.getMostSignificantBits()); out.writeLong(reward.getLeastSignificantBits());
|
||||
out.writeUTF("VOID");
|
||||
}
|
||||
var repository = new FileStateRepository(path);
|
||||
var loaded = repository.load();
|
||||
assertEquals(new ProgressLimits(120, 313, 60), loaded.limits());
|
||||
assertEquals(loaded.players().get(player).pendingRewards(), loaded.players().get(player).issuedRewards());
|
||||
assertEquals(Track.VOID, loaded.players().get(player).issuedRewards().get(reward));
|
||||
repository.save(loaded);
|
||||
assertEquals(loaded, repository.load());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliveredItemReceiptsSurviveRestartAfterPendingDeliveryIsAcknowledged() throws Exception {
|
||||
Path path = directory.resolve("state.bin");
|
||||
var expected = new IgnisState(ProgressLimits.defaults(), Map.of(UUID.randomUUID(),
|
||||
new PlayerState(Map.of(), Map.of(), Map.of(UUID.randomUUID(), Track.VOID))));
|
||||
new FileStateRepository(path).save(expected);
|
||||
assertEquals(expected, new FileStateRepository(path).load());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingStateStartsEmptyAndInterruptedTemporaryFilesAreNeverPromoted() throws Exception {
|
||||
java.nio.file.Files.writeString(directory.resolve(".ignis-interrupted.tmp"), "incomplete");
|
||||
@@ -23,7 +54,7 @@ class FileStateRepositoryTest {
|
||||
repository.save(IgnisState.empty());
|
||||
byte[] valid = java.nio.file.Files.readAllBytes(path);
|
||||
byte[] future = valid.clone();
|
||||
future[7] = 2;
|
||||
future[7] = 3;
|
||||
byte[] negativeCount = valid.clone();
|
||||
java.util.Arrays.fill(negativeCount, 32, 36, (byte) 0xff);
|
||||
for (byte[] invalid : java.util.List.of(new byte[0], java.util.Arrays.copyOf(valid, 12),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartConditionsTest {
|
||||
@Test
|
||||
void darknessWorksEverywhereButNightOnlyWorksInNormalCycleWorlds() {
|
||||
assertTrue(HeartConditions.active(true, true, false));
|
||||
assertTrue(HeartConditions.active(true, false, false));
|
||||
assertTrue(HeartConditions.active(true, false, true));
|
||||
assertTrue(HeartConditions.active(false, true, true));
|
||||
assertFalse(HeartConditions.active(false, true, false));
|
||||
assertFalse(HeartConditions.active(false, false, true));
|
||||
assertFalse(HeartConditions.active(false, false, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HeartCraftingTest {
|
||||
@TempDir Path directory;
|
||||
@Test
|
||||
void ingredientValidationCannotBeBypassedWithAnAuthenticatedPreview() throws Exception {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
states.update(state -> new TradableRewards(Track.HEART).issue(state, owner, receipt, true)).get();
|
||||
var items = mock(HeartItems.class);
|
||||
var product = mock(ItemStack.class);
|
||||
when(items.identify(product)).thenReturn(java.util.Optional.of(new ItemToken(owner, receipt, 0)));
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
when(recipe.getKey()).thenReturn(HeartCrafting.RECIPE);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
when(inventory.getResult()).thenReturn(product);
|
||||
when(inventory.getMatrix()).thenReturn(new ItemStack[9]);
|
||||
var event = mock(CraftItemEvent.class);
|
||||
when(event.getInventory()).thenReturn(inventory);
|
||||
when(event.getRecipe()).thenReturn(recipe);
|
||||
when(event.getWhoClicked()).thenReturn(player);
|
||||
new HeartCrafting(null, states, items, null).onCraft(event);
|
||||
verify(event).setCancelled(true);
|
||||
verify(inventory).setResult(null);
|
||||
verify(inventory, never()).setMatrix(any());
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void recipeUsesOrthogonalBottlesAndCornerHealingWithCentralHeart() {
|
||||
var recipe = mock(ShapedRecipe.class);
|
||||
var bottle = mock(RecipeChoice.ExactChoice.class);
|
||||
var healing = mock(RecipeChoice.ExactChoice.class);
|
||||
HeartCrafting.configureRecipe(recipe, bottle, healing);
|
||||
verify(recipe).shape("PBP", "BCB", "PBP");
|
||||
verify(recipe).setIngredient('B', bottle);
|
||||
verify(recipe).setIngredient('P', healing);
|
||||
verify(recipe).setIngredient('C', Material.CREAKING_HEART);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartEffectsTest {
|
||||
@Test
|
||||
void ticksEachPlayerOnceAndWithdrawsBeforeDiscardingSessionOwnership() {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(Server.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
var player = mock(Player.class);
|
||||
var id = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||
var regeneration = mock(HeartRegeneration.class);
|
||||
var channel = mock(HeartRegeneration.Channel.class);
|
||||
var effects = new HeartEffects(plugin, regeneration, ignored -> true, ignored -> channel);
|
||||
effects.run();
|
||||
verify(regeneration, times(1)).tick(id, true, channel);
|
||||
effects.close();
|
||||
var ordered = inOrder(regeneration);
|
||||
ordered.verify(regeneration).tick(id, true, channel);
|
||||
ordered.verify(regeneration).tick(id, false, channel);
|
||||
ordered.verify(regeneration).forget(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartEligibilityTest {
|
||||
@Test
|
||||
void inventoryPresenceNeedsPersonalUnlockValidIssuerAndDarknessOrNormalNight() {
|
||||
UUID issuer = UUID.randomUUID(), holder = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var token = new ItemToken(issuer, receipt, 0);
|
||||
var state = new TradableRewards(Track.HEART).issue(IgnisState.empty(), issuer, receipt, true);
|
||||
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), true, true, true));
|
||||
state = ProgressAdministration.progress(state, holder, Track.HEART, state.limits().darknessMillis(), false);
|
||||
assertTrue(HeartEligibility.eligible(state, holder, List.of(token, token), false, true, true));
|
||||
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), false, false, true));
|
||||
assertTrue(HeartEligibility.eligible(state, holder, List.of(token), true, false, false));
|
||||
assertFalse(HeartEligibility.eligible(state, holder, List.of(), true, true, true));
|
||||
state = ProgressAdministration.reset(state, issuer, Set.of(Track.HEART));
|
||||
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), true, true, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartEnvironmentTest {
|
||||
@Test
|
||||
void totalLightMustBeZeroAndUnloadedChunksAreNotQueried() {
|
||||
var world = mock(World.class);
|
||||
var block = mock(Block.class);
|
||||
var location = new Location(world, -1, 64, 32);
|
||||
assertFalse(HeartEnvironment.dark(location));
|
||||
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
when(world.isChunkLoaded(-1, 2)).thenReturn(true);
|
||||
when(world.getBlockAt(-1, 64, 32)).thenReturn(block);
|
||||
when(block.getLightLevel()).thenReturn((byte) 0);
|
||||
assertTrue(HeartEnvironment.dark(location));
|
||||
when(block.getLightLevel()).thenReturn((byte) 1);
|
||||
assertFalse(HeartEnvironment.dark(location));
|
||||
assertFalse(HeartEnvironment.dark(null));
|
||||
assertFalse(HeartEnvironment.dark(new Location(null, 0, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.OminousBottleMeta;
|
||||
import org.bukkit.inventory.meta.PotionMeta;
|
||||
import org.bukkit.potion.PotionType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartIngredientsTest {
|
||||
@Test
|
||||
void validatesAllNineRecipePositionsRatherThanOnlyMaterialNames() {
|
||||
var bottle = mock(ItemStack.class);
|
||||
var omen = mock(OminousBottleMeta.class);
|
||||
when(bottle.getType()).thenReturn(Material.OMINOUS_BOTTLE);
|
||||
when(bottle.getItemMeta()).thenReturn(omen);
|
||||
when(omen.hasAmplifier()).thenReturn(true);
|
||||
when(omen.getAmplifier()).thenReturn(4);
|
||||
var potion = mock(ItemStack.class);
|
||||
var meta = mock(PotionMeta.class);
|
||||
when(potion.getType()).thenReturn(Material.LINGERING_POTION);
|
||||
when(potion.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
|
||||
var center = mock(ItemStack.class);
|
||||
when(center.getType()).thenReturn(Material.CREAKING_HEART);
|
||||
ItemStack[] grid = {potion, bottle, potion, bottle, center, bottle, potion, bottle, potion};
|
||||
assertTrue(HeartIngredients.matrix(grid));
|
||||
grid[0] = bottle;
|
||||
assertFalse(HeartIngredients.matrix(grid));
|
||||
grid[0] = potion;
|
||||
grid[4] = potion;
|
||||
assertFalse(HeartIngredients.matrix(grid));
|
||||
assertFalse(HeartIngredients.matrix(new ItemStack[4]));
|
||||
assertFalse(HeartIngredients.matrix(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresBottleFiveAndLingeringInstantHealthTwoWithoutAddedEffects() {
|
||||
var bottle = mock(ItemStack.class);
|
||||
var omen = mock(OminousBottleMeta.class);
|
||||
when(bottle.getType()).thenReturn(Material.OMINOUS_BOTTLE);
|
||||
when(bottle.getItemMeta()).thenReturn(omen);
|
||||
when(omen.hasAmplifier()).thenReturn(true);
|
||||
when(omen.getAmplifier()).thenReturn(4); // Zero-based amplifier: level V.
|
||||
assertTrue(HeartIngredients.bottle(bottle));
|
||||
when(omen.getAmplifier()).thenReturn(3);
|
||||
assertFalse(HeartIngredients.bottle(bottle));
|
||||
var potion = mock(ItemStack.class);
|
||||
var meta = mock(PotionMeta.class);
|
||||
when(potion.getType()).thenReturn(Material.LINGERING_POTION);
|
||||
when(potion.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
|
||||
assertTrue(HeartIngredients.healing(potion));
|
||||
when(meta.hasCustomEffects()).thenReturn(true);
|
||||
assertFalse(HeartIngredients.healing(potion));
|
||||
when(meta.hasCustomEffects()).thenReturn(false);
|
||||
when(meta.getBasePotionType()).thenReturn(PotionType.HEALING);
|
||||
assertFalse(HeartIngredients.healing(potion));
|
||||
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
|
||||
when(potion.getType()).thenReturn(Material.SPLASH_POTION);
|
||||
assertFalse(HeartIngredients.healing(potion));
|
||||
assertFalse(HeartIngredients.bottle(null));
|
||||
assertFalse(HeartIngredients.healing(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartInitializationTest {
|
||||
@Test
|
||||
void initializationAcknowledgesOnlyDurablyClearedUnfinishedProgress() throws Exception {
|
||||
var id = UUID.randomUUID();
|
||||
class Repository implements StateRepository {
|
||||
IgnisState saved = ProgressAdministration.progress(IgnisState.empty(), id, Track.HEART, 123, false);
|
||||
@Override public IgnisState load() { return saved; }
|
||||
@Override public void save(IgnisState state) { saved = state; }
|
||||
}
|
||||
var repository = new Repository();
|
||||
try (var service = StateService.open(repository).get()) {
|
||||
assertSame(service, HeartProgressState.prepare(service).get());
|
||||
assertEquals(0, service.snapshot().players().get(id).track(Track.HEART).progress());
|
||||
assertEquals(0, repository.saved.players().get(id).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void failedNormalizationKeepsOriginalStateAndClosesTheWorker() throws Exception {
|
||||
var original = ProgressAdministration.progress(IgnisState.empty(), UUID.randomUUID(), Track.HEART, 123, false);
|
||||
var repository = new StateRepository() {
|
||||
@Override public IgnisState load() { return original; }
|
||||
@Override public void save(IgnisState state) throws IOException { throw new IOException("fixture storage failure"); }
|
||||
};
|
||||
try (var service = StateService.open(repository).get()) {
|
||||
assertThrows(CompletionException.class, () -> HeartProgressState.prepare(service).join());
|
||||
assertEquals(original, service.snapshot());
|
||||
var failure = assertThrows(CompletionException.class, () -> service.update(state -> state).join());
|
||||
assertInstanceOf(RejectedExecutionException.class, failure.getCause());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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 HeartItemsTest {
|
||||
@Test
|
||||
void onlyHeartMaterialAndItsOwnReceiptNamespaceAuthenticate() {
|
||||
var item = mock(ItemStack.class);
|
||||
var meta = mock(ItemMeta.class);
|
||||
var tags = mock(PersistentDataContainer.class);
|
||||
when(item.getType()).thenReturn(Material.CREAKING_HEART);
|
||||
when(item.hasItemMeta()).thenReturn(true);
|
||||
when(item.getItemMeta()).thenReturn(meta);
|
||||
when(meta.getPersistentDataContainer()).thenReturn(tags);
|
||||
var items = new HeartItems();
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
var token = new ItemToken(UUID.randomUUID(), UUID.randomUUID(), 2);
|
||||
when(tags.get(new NamespacedKey("spigotignis", "heart_identity"), PersistentDataType.STRING))
|
||||
.thenReturn(EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
|
||||
assertEquals(token, items.identify(item).orElseThrow());
|
||||
assertTrue(new DaggerItems().identify(item).isEmpty());
|
||||
when(item.getType()).thenReturn(Material.OMINOUS_TRIAL_KEY);
|
||||
assertTrue(items.identify(item).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartPlacementTest {
|
||||
@Test
|
||||
void issuedCopiesCannotBecomeBlocksEvenWhenTheirReceiptIsRevoked() {
|
||||
var items = mock(HeartItems.class);
|
||||
var item = mock(ItemStack.class);
|
||||
var event = mock(BlockPlaceEvent.class);
|
||||
when(event.getItemInHand()).thenReturn(item);
|
||||
when(items.identify(item)).thenReturn(Optional.of(new ItemToken(UUID.randomUUID(), UUID.randomUUID(), 0)));
|
||||
new HeartPlacement(items).onPlace(event);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
@Test
|
||||
void ordinaryCreakingHeartsRemainPlaceable() {
|
||||
var items = mock(HeartItems.class);
|
||||
var item = mock(ItemStack.class);
|
||||
var event = mock(BlockPlaceEvent.class);
|
||||
when(event.getItemInHand()).thenReturn(item);
|
||||
when(items.identify(item)).thenReturn(Optional.empty());
|
||||
new HeartPlacement(items).onPlace(event);
|
||||
verify(event, never()).setCancelled(anyBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartProgressListenerTest {
|
||||
@Test
|
||||
void observesOnlinePlayersAndInterruptsOnlyUncancelledPositiveDamage() {
|
||||
var plugin = mock(JavaPlugin.class);
|
||||
var server = mock(Server.class);
|
||||
var player = mock(Player.class);
|
||||
var id = UUID.randomUUID();
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||
var progress = mock(DarknessProgress.class);
|
||||
when(progress.checkpoint(id)).thenReturn(CompletableFuture.completedFuture(IgnisState.empty()));
|
||||
var listener = new HeartProgressListener(plugin, progress, ignored -> true, error -> { throw new AssertionError(error); });
|
||||
listener.run();
|
||||
verify(progress).observe(id, true);
|
||||
var damage = mock(EntityDamageEvent.class);
|
||||
when(damage.getEntity()).thenReturn(player);
|
||||
when(damage.getFinalDamage()).thenReturn(1.0);
|
||||
when(damage.isCancelled()).thenReturn(true);
|
||||
listener.onDamage(damage);
|
||||
verify(progress, never()).interrupt(id);
|
||||
when(damage.isCancelled()).thenReturn(false);
|
||||
when(damage.getFinalDamage()).thenReturn(0.0);
|
||||
listener.onDamage(damage);
|
||||
verify(progress, never()).interrupt(id);
|
||||
when(damage.getFinalDamage()).thenReturn(1.0);
|
||||
listener.onDamage(damage);
|
||||
verify(progress).interrupt(id);
|
||||
verify(progress).checkpoint(id);
|
||||
}
|
||||
@Test
|
||||
void enteringLightAndDisconnectingInterruptButCancelledMovementDoesNot() {
|
||||
var player = mock(Player.class);
|
||||
var id = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var progress = mock(DarknessProgress.class);
|
||||
when(progress.checkpoint(id)).thenReturn(CompletableFuture.completedFuture(IgnisState.empty()));
|
||||
var listener = new HeartProgressListener(null, progress, ignored -> false, error -> { throw new AssertionError(error); });
|
||||
var move = mock(PlayerMoveEvent.class);
|
||||
when(move.getPlayer()).thenReturn(player);
|
||||
when(move.getTo()).thenReturn(new Location(null, 0, 0, 0));
|
||||
when(move.isCancelled()).thenReturn(true);
|
||||
listener.onMove(move);
|
||||
verifyNoInteractions(progress);
|
||||
when(move.isCancelled()).thenReturn(false);
|
||||
listener.onMove(move);
|
||||
verify(progress).interrupt(id);
|
||||
var quit = mock(PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(player);
|
||||
listener.onQuit(quit);
|
||||
verify(progress, times(2)).interrupt(id);
|
||||
verify(progress).checkpoint(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HeartProgressStateTest {
|
||||
@Test
|
||||
void restartClearsOnlyUnfinishedDarknessWithoutRevokingCompletedRewardsOrOtherTracks() {
|
||||
UUID unfinished = UUID.randomUUID(), finished = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var state = ProgressAdministration.progress(IgnisState.empty(), unfinished, Track.HEART, 1000, false);
|
||||
state = ProgressAdministration.progress(state, unfinished, Track.DAGGER, 11, false);
|
||||
state = new TradableRewards(Track.HEART).issue(state, finished, receipt, true);
|
||||
var restarted = HeartProgressState.restart(state);
|
||||
assertEquals(0, restarted.players().get(unfinished).track(Track.HEART).progress());
|
||||
assertEquals(11, restarted.players().get(unfinished).track(Track.DAGGER).progress());
|
||||
assertEquals(state.players().get(finished), restarted.players().get(finished));
|
||||
assertEquals(1000, state.players().get(unfinished).track(Track.HEART).progress());
|
||||
assertEquals(state.limits(), restarted.limits());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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 HeartRegenerationTest {
|
||||
@Test
|
||||
void shutdownRestoresWeakerEffectsEvenWhenDisabledListenersReceiveNoEvents() {
|
||||
var controller = new HeartRegeneration(() -> 0L);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
|
||||
boolean events = true;
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) {
|
||||
effect = next;
|
||||
if (events) controller.changed(id, true, true);
|
||||
}
|
||||
@Override public void remove() { effect = null; }
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
channel.events = false;
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(new HeartRegeneration.Effect(0, 200, false, true, true), channel.effect);
|
||||
}
|
||||
|
||||
@Test
|
||||
void endingSessionDropsOwnershipRatherThanRestoringAnOfflineAgedBackupLater() {
|
||||
var controller = new HeartRegeneration(() -> 0L);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect;
|
||||
int removals;
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
|
||||
@Override public void remove() { removals++; } // External plugin vetoes removal.
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
controller.forget(id);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(1, channel.removals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationAndStrongerExternalEffectsTakePrecedenceAndInfiniteBackupStaysInfinite() {
|
||||
var controller = new HeartRegeneration(() -> 1000L);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, -1, false, true, true);
|
||||
boolean allowApply, allowRemove;
|
||||
int removals;
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) {
|
||||
if (allowApply) { effect = next; controller.changed(id, true, true); }
|
||||
}
|
||||
@Override public void remove() {
|
||||
if (allowRemove) { removals++; effect = null; controller.changed(id, true, false); }
|
||||
}
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(0, channel.effect.amplifier());
|
||||
channel.allowApply = true;
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(1, channel.effect.amplifier(), "Cancelled removal is respected");
|
||||
channel.allowRemove = true;
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(-1, channel.effect.ticks());
|
||||
channel.effect = new HeartRegeneration.Effect(2, 400, false, true, true);
|
||||
controller.changed(id, true, true);
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(2, channel.effect.amplifier());
|
||||
assertEquals(1, channel.removals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExternalClearCannotResurrectTheWeakerEffectOnLaterUnequip() {
|
||||
var controller = new HeartRegeneration(() -> 0L);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
|
||||
@Override public void remove() { effect = null; controller.changed(id, true, false); }
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
channel.effect = null;
|
||||
controller.changed(id, true, false); // Milk or another plugin cleared regeneration.
|
||||
controller.tick(id, true, channel);
|
||||
controller.tick(id, false, channel);
|
||||
assertNull(channel.effect);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlaysRegenerationTwoAndRestoresOnlyTheRemainingWeakerEffect() {
|
||||
var clock = new AtomicLong();
|
||||
var controller = new HeartRegeneration(clock::get);
|
||||
var id = UUID.randomUUID();
|
||||
class Channel implements HeartRegeneration.Channel {
|
||||
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
|
||||
@Override public HeartRegeneration.Effect current() { return effect; }
|
||||
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
|
||||
@Override public void remove() { effect = null; controller.changed(id, true, false); }
|
||||
}
|
||||
var channel = new Channel();
|
||||
controller.tick(id, true, channel);
|
||||
assertEquals(1, channel.effect.amplifier());
|
||||
assertEquals(40, channel.effect.ticks());
|
||||
for (int tick = 10; tick <= 100; tick += 10) {
|
||||
clock.set(tick);
|
||||
var old = channel.effect;
|
||||
channel.effect = new HeartRegeneration.Effect(old.amplifier(), old.ticks() - 10, old.ambient(), old.particles(), old.icon());
|
||||
controller.tick(id, true, channel);
|
||||
}
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(new HeartRegeneration.Effect(0, 100, false, true, true), channel.effect);
|
||||
controller.tick(id, false, channel);
|
||||
assertEquals(0, channel.effect.amplifier(), "Restored external regeneration is not claimed as ours");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class IgnisCommandsTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
|
||||
|
||||
@Test
|
||||
void administrativeHeartSetUsesTheTrackerRebaseBarrier() 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("heart.bin"))).get()) {
|
||||
states.update(state -> ProgressAdministration.progress(state, owner, Track.HEART, 0, false)).get();
|
||||
var clock = new java.util.concurrent.atomic.AtomicLong();
|
||||
var tracker = new DarknessProgress(states, clock::get);
|
||||
tracker.observe(owner, true);
|
||||
clock.set(100_000_000L);
|
||||
tracker.observe(owner, true);
|
||||
var commands = new IgnisCommands(plugin, states, dispatch, null, null, tracker);
|
||||
commands.onCommand(sender, command, "ignisadmin", new String[]{"progress", "set", owner.toString(), "heart", "2"});
|
||||
assertTrue(saved.await(3, java.util.concurrent.TimeUnit.SECONDS));
|
||||
tracker.observe(owner, true);
|
||||
tracker.checkpoint(owner).get();
|
||||
assertEquals(2, states.snapshot().players().get(owner).track(Track.HEART).progress());
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
|
||||
var sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission("ignis.admin")).thenReturn(true);
|
||||
var command = mock(Command.class);
|
||||
when(command.getName()).thenReturn("ignisadmin");
|
||||
var owner = java.util.UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
states.update(state -> new IgnisState(state.limits(), java.util.Map.of(owner, PlayerState.empty()))).get();
|
||||
var handler = new IgnisCommands(plugin, states, mock(EchoGameplay.class));
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", owner.toString(), "echo"});
|
||||
states.update(state -> state).get();
|
||||
assertEquals(1, states.snapshot().players().get(owner).pendingRewards().size());
|
||||
assertTrue(states.snapshot().players().get(owner).track(Track.VOID).unlocked());
|
||||
assertFalse(states.snapshot().players().get(owner).track(Track.VOID).activated());
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"limits", "set", "mask-ms", "100"});
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"progress", "set", owner.toString(), "mask", "100"});
|
||||
states.update(state -> state).get();
|
||||
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("dagger", "echo", "heart", "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());
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", owner.toString(), "heart"});
|
||||
states.update(state -> state).get();
|
||||
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.HEART));
|
||||
assertTrue(states.snapshot().players().get(owner).track(Track.HEART).unlocked());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryPlayerCanPrivatelyViewAllThreeTracksWithoutAdminPermission() throws Exception {
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var command = mock(Command.class);
|
||||
when(command.getName()).thenReturn("ignis");
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
var handler = new IgnisCommands(mock(JavaPlugin.class), states, mock(EchoGameplay.class));
|
||||
handler.onCommand(player, command, "ignis", new String[]{"umbra"});
|
||||
verify(player).sendMessage(argThat((String text) -> text.contains("Invisibility") && text.contains("Pearls") && text.contains("Darkness")));
|
||||
verify(player, times(1)).sendMessage(anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetRequiresMatchingPriorConfirmationAndThenRevokesTheTrack() 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 command = mock(Command.class);
|
||||
when(command.getName()).thenReturn("ignisadmin");
|
||||
var owner = java.util.UUID.randomUUID();
|
||||
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
|
||||
states.update(state -> EchoRewards.award(state, owner, java.util.UUID.randomUUID())).get();
|
||||
var handler = new IgnisCommands(plugin, states, mock(EchoGameplay.class));
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"reset", owner.toString(), "void", "confirm"});
|
||||
states.update(state -> state).get();
|
||||
assertTrue(states.snapshot().players().get(owner).track(Track.VOID).unlocked());
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"reset", owner.toString(), "void"});
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"reset", owner.toString(), "void", "confirm"});
|
||||
states.update(state -> state).get();
|
||||
assertFalse(states.snapshot().players().get(owner).track(Track.VOID).unlocked());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionDenialIsEnforcedAtExecutionAndAutocomplete() {
|
||||
var sender = mock(CommandSender.class);
|
||||
var command = mock(Command.class);
|
||||
when(command.getName()).thenReturn("ignisadmin");
|
||||
var handler = new IgnisCommands(mock(JavaPlugin.class), null, null);
|
||||
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", "someone", "echo"});
|
||||
verify(sender).sendMessage("You do not have permission to administer Ignis.");
|
||||
assertTrue(handler.onTabComplete(sender, command, "ignisadmin", new String[]{""}).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,11 @@ class PluginPackagingTest {
|
||||
assertFalse(descriptor.contains("${version}"));
|
||||
assertTrue(org.bukkit.plugin.java.JavaPlugin.class.isAssignableFrom(
|
||||
Class.forName("games.dmg.spigotignis.SpigotIgnisPlugin", false, getClass().getClassLoader())));
|
||||
assertFalse(descriptor.contains("commands:"), "Do not advertise unimplemented commands");
|
||||
assertTrue(descriptor.contains("commands:"));
|
||||
assertTrue(descriptor.contains(" ignis:"));
|
||||
assertTrue(descriptor.contains(" ignisadmin:"));
|
||||
assertTrue(descriptor.contains("permission: ignis.admin"));
|
||||
assertTrue(descriptor.contains("default: op"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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 ProgressAdministrationTest {
|
||||
@Test
|
||||
void progressAdjustmentIsValidatedAndUnlocksAtThresholdWithoutChangingOtherTracks() {
|
||||
UUID id = UUID.randomUUID();
|
||||
var state = ProgressAdministration.progress(IgnisState.empty(), id, Track.DAGGER, 312, false);
|
||||
assertEquals(312, state.players().get(id).track(Track.DAGGER).progress());
|
||||
assertFalse(state.players().get(id).track(Track.DAGGER).unlocked());
|
||||
var unlocked = ProgressAdministration.progress(state, id, Track.DAGGER, 1, true);
|
||||
assertTrue(unlocked.players().get(id).track(Track.DAGGER).unlocked());
|
||||
assertEquals(TrackState.empty(), unlocked.players().get(id).track(Track.MASK));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ProgressAdministration.progress(state, id, Track.DAGGER, -1, true));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ProgressAdministration.progress(state, id, Track.VOID, 1, false));
|
||||
var maximum = ProgressAdministration.progress(state, id, Track.DAGGER, Long.MAX_VALUE, false);
|
||||
assertThrows(ArithmeticException.class,
|
||||
() -> ProgressAdministration.progress(maximum, id, Track.DAGGER, 1, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetRevokesActivationAndOldTokensStayInvalidAfterReearning() {
|
||||
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
|
||||
var token = new EchoToken(owner, receipt, 0);
|
||||
var before = EchoRewards.activate(EchoRewards.award(IgnisState.empty(), owner, receipt), owner, token);
|
||||
var reset = ProgressAdministration.reset(before, owner, Set.of(Track.VOID));
|
||||
assertFalse(reset.players().get(owner).track(Track.VOID).activated());
|
||||
assertEquals(1, reset.players().get(owner).track(Track.VOID).generation());
|
||||
UUID replacement = UUID.randomUUID();
|
||||
var earned = EchoRewards.award(reset, owner, replacement);
|
||||
assertFalse(EchoRewards.valid(earned, owner, token));
|
||||
assertTrue(EchoRewards.valid(earned, owner, new EchoToken(owner, replacement, 1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ProgressInspectionTest {
|
||||
@TempDir Path directory;
|
||||
private final UUID owner = UUID.randomUUID(), worldId = UUID.randomUUID();
|
||||
private final JavaPlugin plugin = mock(JavaPlugin.class);
|
||||
private final Server server = mock(Server.class);
|
||||
private final Player player = mock(Player.class);
|
||||
private final World world = mock(World.class);
|
||||
private final CommandSender admin = mock(CommandSender.class);
|
||||
private final Command command = mock(Command.class);
|
||||
private final EchoItems items = mock(EchoItems.class);
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private StateService states;
|
||||
private EchoGameplay gameplay;
|
||||
private IgnisCommands handler;
|
||||
private Path file;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
file = directory.resolve("state.bin");
|
||||
states = StateService.open(new FileStateRepository(file)).get();
|
||||
states.update(state -> new IgnisState(state.limits(), Map.of(owner, PlayerState.empty()))).get();
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getName()).thenReturn("Target");
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getY()).thenReturn(-65.0);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(server.getPlayerExact("Target")).thenReturn(player);
|
||||
when(admin.hasPermission("ignis.admin")).thenReturn(true);
|
||||
when(command.getName()).thenReturn("ignisadmin");
|
||||
doAnswer(call -> { messages.add(call.getArgument(0, String.class)); return null; })
|
||||
.when(admin).sendMessage(anyString());
|
||||
gameplay = new EchoGameplay(plugin, states, items);
|
||||
handler = new IgnisCommands(plugin, states, gameplay);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void close() { if (states != null) states.close(); }
|
||||
|
||||
@Test
|
||||
void commandReportsRealGameplayAttemptStagesWithoutSavingOrInterruptingThem() throws Exception {
|
||||
var before = states.snapshot();
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
EntityDamageEvent damage = mock(EntityDamageEvent.class);
|
||||
when(damage.getEntity()).thenReturn(player);
|
||||
when(damage.getCause()).thenReturn(EntityDamageEvent.DamageCause.VOID);
|
||||
when(damage.getFinalDamage()).thenReturn(1.0);
|
||||
gameplay.onDamage(damage);
|
||||
inspect("Target");
|
||||
assertContains("Void challenge: awaiting-elytra-ascent");
|
||||
assertContains("world=" + worldId);
|
||||
assertContains("damage-y=-65.0");
|
||||
when(player.isGliding()).thenReturn(true);
|
||||
gameplay.onMove(new PlayerMoveEvent(player, new Location(world, 0, -65, 0), new Location(world, 0, 20, 0)));
|
||||
messages.clear();
|
||||
inspect("Target");
|
||||
assertContains("Void challenge: awaiting-end-stone");
|
||||
var quit = mock(PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(player);
|
||||
gameplay.onQuit(quit);
|
||||
messages.clear();
|
||||
inspect(owner.toString());
|
||||
assertContains("Void challenge: none");
|
||||
assertSame(before, states.snapshot());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(file));
|
||||
verifyNoInteractions(items);
|
||||
verify(plugin, never()).getLogger();
|
||||
verify(server, never()).getScheduler();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedProgressAndRecipeUnlocksDoNotMisreportAnUnconsumedEchoAsActive() throws Exception {
|
||||
UUID receipt = UUID.randomUUID();
|
||||
states.update(state -> EchoRewards.award(ProgressAdministration.progress(
|
||||
ProgressAdministration.progress(state, owner, Track.MASK, state.limits().invisibilityMillis(), false),
|
||||
owner, Track.DAGGER, 3, false), owner, receipt)).get();
|
||||
var before = states.snapshot();
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
inspect(owner.toString());
|
||||
assertContains("MASK=" + before.limits().invisibilityMillis());
|
||||
assertContains("DAGGER=3");
|
||||
assertContains("HEART=0");
|
||||
assertContains("Recipe unlocks: mask=unlocked, dagger=locked, heart=locked");
|
||||
assertContains("Echo: eligibility=unlocked; advancement-name scrambling=inactive");
|
||||
assertContains("pending=1");
|
||||
assertContains("issued=1");
|
||||
assertSame(before, states.snapshot());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(file));
|
||||
verify(server, never()).getPlayerExact(anyString());
|
||||
verifyNoInteractions(items);
|
||||
states.update(state -> EchoRewards.activate(state, owner, new EchoToken(owner, receipt, 0))).get();
|
||||
messages.clear();
|
||||
inspect(owner.toString());
|
||||
assertContains("advancement-name scrambling=active");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminInspectionIsDeniedBeforeReadingOrResolvingState() {
|
||||
when(admin.hasPermission("ignis.admin")).thenReturn(false);
|
||||
var denied = new IgnisCommands(plugin, null, null);
|
||||
denied.onCommand(admin, command, "ignisadmin", new String[] {"inspect", "Target"});
|
||||
assertEquals(List.of("You do not have permission to administer Ignis."), messages);
|
||||
verifyNoInteractions(server);
|
||||
}
|
||||
|
||||
private void inspect(String target) {
|
||||
assertTrue(handler.onCommand(admin, command, "ignisadmin", new String[] {"inspect", target}));
|
||||
}
|
||||
|
||||
private void assertContains(String fragment) {
|
||||
assertTrue(messages.stream().anyMatch(message -> message.contains(fragment)),
|
||||
() -> "Missing '" + fragment + "' in " + messages);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user