feat(ignis): expose active challenge inspection
Release / release (push) Successful in 2m45s
CI / build (push) Successful in 1m23s

This commit is contained in:
dmg committed 2026-09-12 09:56:47 -04:00
1 parent 79c9c7fea7
commit 7edfe71d2d
6 files changed
+204 -2

No files matched your search

+2
View File
@@ -102,6 +102,8 @@ Administration requires **`ignis.admin`** (default: operators), checked independ
/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.
@@ -76,6 +76,11 @@ public final class EchoGameplay implements Listener {
@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();
}
@@ -69,8 +69,19 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
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());
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")) {
@@ -6,6 +6,7 @@ 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<>();
@@ -30,5 +31,12 @@ public final class VoidTrial {
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); }
}
@@ -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);
}
}
@@ -5,6 +5,42 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
class VoidTrialTest {
@Test
void inspectionIsImmutableAndDoesNotChangeChallengeCompletion() {
var trial = new VoidTrial();
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
assertTrue(trial.snapshot(player).isEmpty());
trial.damaged(player, world, -65);
assertTrue(trial.snapshot(player).isPresent(), "Active attempt must be inspectable");
var initial = trial.snapshot(player).orElseThrow();
assertEquals(world, initial.world());
assertEquals(-65, initial.damageHeight());
assertFalse(initial.escaped());
assertEquals(initial, trial.snapshot(player).orElseThrow());
trial.moved(player, world, 20, false, false, false);
assertFalse(trial.snapshot(player).orElseThrow().escaped());
trial.moved(player, world, 20, true, false, false);
assertTrue(trial.snapshot(player).orElseThrow().escaped());
assertFalse(initial.escaped(), "A returned snapshot must not change after later events");
assertTrue(trial.moved(player, world, 60, false, true, true));
assertTrue(trial.snapshot(player).isEmpty());
assertFalse(trial.moved(player, world, 60, false, true, true));
}
@Test
void inspectionClearsAfterCancellationWorldChangeAndRestart() {
var trial = new VoidTrial();
UUID player = UUID.randomUUID(), world = UUID.randomUUID();
trial.damaged(player, world, -65);
trial.cancel(player);
assertTrue(trial.snapshot(player).isEmpty());
trial.damaged(player, world, -65);
trial.moved(player, UUID.randomUUID(), 60, true, false, false);
assertTrue(trial.snapshot(player).isEmpty());
trial.damaged(player, world, -65);
assertTrue(new VoidTrial().snapshot(player).isEmpty());
}
@Test
void requiresDamageAscentWhileGlidingAndFirstLandingOnEndStone() {
var trial = new VoidTrial();