feat(ignis): add bound Echo challenge and announcement masking
This commit is contained in:
1 parent
9d3791a9c3
commit
5ee5876830
28 files changed
+1199
-20
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 and advancement-name scrambling are implemented in source; live-server verification is pending.** The Mask, Dagger, and Heart gameplay are not implemented yet.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -17,21 +17,52 @@ Targets Purpur `26.2.build.2618-stable`. The API is not bundled. Source builds d
|
||||
|
||||
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. Future tradable-item provenance remains a later design decision.
|
||||
- `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. No ProtocolLib dependency is introduced. Existing Stealth outbound masking sees the obfuscated alias rather than the original name. Live coexistence remains unverified.
|
||||
|
||||
## Commands
|
||||
|
||||
All players may use `/ignis umbra` to privately see the three item tracks. Their natural counters and items are not active yet; administrator-seeded values can be inspected. No passive progress UI is added.
|
||||
|
||||
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
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all>
|
||||
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all> confirm
|
||||
```
|
||||
|
||||
Reset confirmation must match the same sender, target, and tracks within 30 seconds; `confirm` alone cannot bypass the initial request. Reset clears the selected progress/unlocks/activation, pending rewards and issuance receipts, and advances generation, making old Echo copies invalid even after re-earning. Other tracks are unchanged by a single-track reset. Echo grants unlock eligibility but still require consumption to activate scrambling; already activated players do not gain another activation. Admin mutations are audited, not routine private progress reads.
|
||||
|
||||
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 +70,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 release, test: void damage/elytra/End Stone success and all interruption paths; other players and lookalikes failing activation; full inventory and failed saves; logout/death during activation; reset/re-earn invalidation; all advancement types and hover/click content with Stealth; and admin permission/confirmation/offline UUID cases.
|
||||
@@ -18,6 +18,7 @@ dependencies {
|
||||
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,151 @@
|
||||
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); }
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -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,170 @@
|
||||
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 Map<CommandSender, Confirmation> confirmations = new HashMap<>();
|
||||
|
||||
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay) {
|
||||
this.plugin = plugin;
|
||||
this.states = states;
|
||||
this.gameplay = gameplay;
|
||||
}
|
||||
|
||||
@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());
|
||||
sender.sendMessage(id + ": " + player.tracks() + "; pending=" + player.pendingRewards().size()
|
||||
+ "; issued=" + player.issuedRewards().size());
|
||||
} 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");
|
||||
mutation(sender, "progress " + id + " " + track + " " + args[1] + " " + value,
|
||||
states.update(state -> ProgressAdministration.progress(state, id, track, value, add)));
|
||||
} 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; 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");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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;
|
||||
|
||||
@@ -14,7 +14,25 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
|
||||
if (error != null) {
|
||||
getLogger().severe("Ignis state unavailable; original data retained. Restore a verified backup 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 commands = new IgnisCommands(this, service, gameplay);
|
||||
for (String name : java.util.List.of("ignis", "ignisadmin")) {
|
||||
var command = java.util.Objects.requireNonNull(getCommand(name), "Missing command " + name);
|
||||
command.setExecutor(commands);
|
||||
command.setTabCompleter(commands);
|
||||
}
|
||||
getLogger().info("Ignis Echo challenge enabled. Further abilities are still in development.");
|
||||
});
|
||||
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
|
||||
service.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
public void cancel(UUID player) { attempts.remove(player); }
|
||||
}
|
||||
@@ -2,4 +2,16 @@ 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 the Echo of the Abyss.
|
||||
commands:
|
||||
ignis:
|
||||
description: Inspect private shadow progression.
|
||||
usage: /ignis umbra
|
||||
ignisadmin:
|
||||
description: Administer Ignis progression and Echo 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,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));
|
||||
}
|
||||
}
|
||||
@@ -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,86 @@
|
||||
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 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("echo"), handler.onTabComplete(sender, command, "ignisadmin", new String[]{"give", owner.toString(), ""}));
|
||||
}
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
@@ -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,50 @@
|
||||
package games.dmg.spigotignis;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class VoidTrialTest {
|
||||
@Test
|
||||
void requiresDamageAscentWhileGlidingAndFirstLandingOnEndStone() {
|
||||
var trial = new VoidTrial();
|
||||
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
trial.damaged(player, world, -65);
|
||||
trial.moved(player, world, 20, false, false, false);
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
trial.damaged(player, world, -65);
|
||||
trial.moved(player, world, -66, true, false, false);
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
trial.damaged(player, world, -65);
|
||||
trial.moved(player, world, 20, true, false, false);
|
||||
assertFalse(trial.moved(player, world, 60, false, true, false));
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationWorldChangeAndNewTrialPreventOldAttemptCompletion() {
|
||||
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
|
||||
var trial = new VoidTrial();
|
||||
trial.damaged(player, world, -65);
|
||||
trial.moved(player, world, 20, true, false, false);
|
||||
trial.cancel(player);
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
trial.damaged(player, world, -65);
|
||||
trial.moved(player, world, 20, true, false, false);
|
||||
assertFalse(trial.moved(player, UUID.randomUUID(), 60, false, true, true));
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
trial.damaged(player, world, -65);
|
||||
assertFalse(new VoidTrial().moved(player, world, 60, false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void survivedVoidDamageThenGlidingEscapeThenEndStoneCompletesOnlyOnce() {
|
||||
var trial = new VoidTrial();
|
||||
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
|
||||
trial.damaged(player, world, -65);
|
||||
assertFalse(trial.moved(player, world, -50, true, false, false));
|
||||
assertTrue(trial.moved(player, world, 60, false, true, true));
|
||||
assertFalse(trial.moved(player, world, 60, false, true, true));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user