feat(pack): export native delivery and client statuses
Release / release (push) Failing after 18m13s

This commit is contained in:
dmg committed 2026-09-26 14:48:15 -04:00
1 parent 2f7a72355d
commit 8f2501f1c0
7 files changed
+690 -15

No files matched your search

@@ -190,6 +190,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> roleplayAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PartyCapacityEventBridge partyCapacityEvents;
private java.util.concurrent.CompletionStage<Void> partyCapacityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private java.util.concurrent.CompletionStage<Void> sheetResourcePackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
@@ -821,6 +823,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Party capacity event intent failed", failure));
admin.attachPartyCapacityEvents(partyCapacityEvents);
sheetResourcePackEvents = new games.dmg.fantasystick.events.SheetResourcePackEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Sheet resource-pack event intent failed", failure));
admin.attachSheetResourcePackEvents(sheetResourcePackEvents);
sheetResourcePack.attachEvents(sheetResourcePackEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -848,7 +855,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = null; partyCapacityEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = null; partyCapacityEvents = null; sheetResourcePackEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -944,6 +951,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous party capacity event intent drain failed", failure);
if (replayPartyCapacityEvents != null) replayPartyCapacityEvents.replay();
});
var replaySheetPackEvents = sheetResourcePackEvents;
sheetResourcePackAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous sheet pack event intent drain failed", failure);
if (replaySheetPackEvents != null) replaySheetPackEvents.replay();
});
var replaySwitchConfigEvents = switchConfigEvents;
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
@@ -1017,6 +1029,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
@@ -1358,6 +1371,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingPartyCapacityEvents.drained());
});
if (closingSheetPackEvents != null) cleanup("sheet resource-pack event intent drain", () -> {
var settingsDrained = closingSheetResourcePackSettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingSheetResourcePackSettings.drained();
sheetResourcePackAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Sheet pack setting drain failed", failure);
closingSheetPackEvents.close();
return null;
}).thenCompose(ignored -> closingSheetPackEvents.drained());
});
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
var settingsDrained = closingSwitchDelaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -0,0 +1,178 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
/** Pack events describe native offers and callbacks, never actual client rendering or gameplay authority. */
public final class SheetResourcePackEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
public record Request(UUID action, UUID pack, String assetIdentity, String sha1) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public SheetResourcePackEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "sheet-resource-pack-event-intents",
java.util.Set.of("config.changed", "resource_pack.delivery_requested",
"resource_pack.delivery_resolved"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), active == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, String key, String value) {
if (actor == null) return;
var values = config(actor, key);
values.put("effective", safeValue(key, value));
values.put("outcome", "acknowledged_read");
var action = UUID.randomUUID();
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* A read cannot change a delivery. */ }
}
public void changed(Actor actor, UUID action, String key, String before, String effective,
SheetResourcePackSettings.Update saved) {
if (actor == null) return;
try {
var values = config(actor, key);
values.put("operation_id", action.toString());
values.put("policy_revision", Long.toString(saved.sequence()));
values.put("before", safeValue(key, before));
values.put("after", safeValue(key, key.equals(SheetResourcePackSettings.URL_KEY)
? saved.snapshot().url() : saved.snapshot().sha1()));
values.put("effective", safeValue(key, effective));
values.put("outcome", "acknowledged_before_optional_redelivery");
durable(action, "config.changed", actor.context(), values);
} catch (RuntimeException ignored) { /* An audit failure cannot reverse a paired setting. */ }
}
public Request requested(Player player, UUID pack, String url, String sha1, String trigger,
boolean delivered) {
var action = UUID.randomUUID();
var request = new Request(action, pack, identity(url), sha1);
try {
var values = new LinkedHashMap<String, Object>();
values.put("pack_id", pack.toString());
values.put("asset_identity", request.assetIdentity());
values.put("sha1", sha1);
values.put("trigger", trigger);
values.put("prompt", "Optional Fantasy Stick first-person sheet presentation");
values.put("required", false);
values.put("outcome", delivered ? "native_offer_returned" : "native_offer_failed");
var context = playerContext(player);
values.put("location_quality", context.location() == null ? "unavailable" : context.location().quality());
if (context.location() == null) values.put("location_status", "unavailable");
durable(action, "resource_pack.delivery_requested", context, values);
} catch (RuntimeException ignored) { /* Pack requests and gameplay never depend on the audit writer. */ }
return request;
}
public void resolved(Player player, Request request, PlayerResourcePackStatusEvent.Status status) {
try {
var outcome = status.name().toLowerCase(Locale.ROOT);
if (status == PlayerResourcePackStatusEvent.Status.FAILED_RELOAD) outcome = "reload_failed";
var values = new LinkedHashMap<String, Object>();
values.put("pack_id", request.pack().toString());
values.put("asset_identity", request.assetIdentity());
values.put("sha1", request.sha1());
values.put("origin_delivery_action_id", request.action().toString());
values.put("status", outcome);
values.put("reason", "client_reported_status");
values.put("callback_time", clock.instant().toString());
var context = playerContext(player);
values.put("location_quality", context.location() == null ? "unavailable" : context.location().quality());
if (context.location() == null) values.put("location_status", "unavailable");
durable(request.action(), "resource_pack.delivery_resolved", context, values, status.name());
} catch (RuntimeException ignored) { /* A status cannot authorize gameplay. */ }
}
private static Map<String, Object> config(Actor actor, String key) {
var values = new LinkedHashMap<String, Object>();
values.put("key", key);
values.put("actor_kind", actor.kind());
values.put("application_scope", "later_optional_redelivery");
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
/** A URL's query can contain credentials; only a one-way identity escapes the policy boundary. */
public static String safeValue(String key, String value) {
return SheetResourcePackSettings.URL_KEY.equals(key) ? identity(value) : value;
}
private static String identity(String url) {
try {
return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(url.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); }
}
private EventInput.Context playerContext(Player player) {
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
safeLocation(player));
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private void durable(UUID action, String type, EventInput.Context context, Map<String, Object> values) {
durable(action, type, context, values, type);
}
private void durable(UUID action, String type, EventInput.Context context, Map<String, Object> values,
String discriminator) {
outbox.accepted(new EventInput(id(action, discriminator), action, clock.instant(), type, context, values));
}
private static UUID id(UUID action, String discriminator) {
return UUID.nameUUIDFromBytes(("fantasystick:sheet-pack:" + action + ":" + discriminator)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -1,7 +1,12 @@
package games.dmg.fantasystick.presentation;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import games.dmg.fantasystick.events.SheetResourcePackEventBridge;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BiConsumer;
@@ -9,6 +14,7 @@ import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
/** Optional cosmetic pack delivery. Status never authorizes or cancels gameplay. */
@@ -16,36 +22,71 @@ public final class SheetResourcePack implements Listener, AutoCloseable {
public static final UUID ID = UUID.fromString("0d53e627-f13b-5a49-b8d5-882d3f4122bb");
private final SheetResourcePackSettings settings;
private final BiConsumer<UUID, Throwable> failures;
private final Map<UUID, Offer> offers = new HashMap<>();
private final Map<UUID, Player> recipients = new HashMap<>();
private SheetResourcePackEventBridge events;
private boolean closed;
private record Offer(SheetResourcePackEventBridge.Request request,
Set<PlayerResourcePackStatusEvent.Status> statuses) { }
public SheetResourcePack(SheetResourcePackSettings settings, BiConsumer<UUID, Throwable> failures) {
this.settings = Objects.requireNonNull(settings);
this.failures = Objects.requireNonNull(failures);
}
public void deliverAll(Collection<? extends Player> players) {
if (closed) return;
for (var player : players) if (player != null && player.isOnline()) deliver(player);
public void attachEvents(SheetResourcePackEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Sheet pack events unavailable");
events = Objects.requireNonNull(bridge);
}
public void deliver(Player player) {
public void deliverAll(Collection<? extends Player> players) { deliverAll(players, "startup"); }
public void deliverAll(Collection<? extends Player> players, String trigger) {
if (closed) return;
for (var player : players) if (player != null && player.isOnline()) deliver(player, trigger);
}
public void deliver(Player player) { deliver(player, "explicit"); }
private void deliver(Player player, String trigger) {
if (closed || !player.isOnline()) return;
var playerId = player.getUniqueId();
var prior = offers.get(playerId);
var pack = UUID.randomUUID(); // Native callbacks expose only this ID, not a usable hash.
var url = settings.url(); var sha1 = settings.sha1();
try {
player.addResourcePack(ID, settings.url(), java.util.HexFormat.of().parseHex(settings.sha1()),
player.addResourcePack(pack, url, java.util.HexFormat.of().parseHex(sha1),
"Optional Fantasy Stick first-person sheet presentation", false);
if (events != null) offers.put(playerId, new Offer(
events.requested(player, pack, url, sha1, trigger, true), new HashSet<>()));
recipients.put(playerId, player);
if (prior != null) try { player.removeResourcePack(prior.request().pack()); }
catch (RuntimeException failure) { report(playerId, failure); }
} catch (RuntimeException failure) {
report(player.getUniqueId(), failure);
player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged.");
if (events != null) events.requested(player, pack, url, sha1, trigger, false);
report(playerId, failure);
try { player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged."); }
catch (RuntimeException feedbackFailure) { report(playerId, feedbackFailure); }
}
}
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer()); }
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer(), "join"); }
@EventHandler public void quit(PlayerQuitEvent event) {
var id = event.getPlayer().getUniqueId(); offers.remove(id); recipients.remove(id);
}
@EventHandler public void status(PlayerResourcePackStatusEvent event) {
if (closed || !ID.equals(event.getID())) return;
if (closed) return;
var offer = offers.get(event.getPlayer().getUniqueId());
if (offer == null || !offer.request().pack().equals(event.getID())) return;
if (offer.statuses().add(event.getStatus()) && events != null)
events.resolved(event.getPlayer(), offer.request(), event.getStatus());
switch (event.getStatus()) {
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED ->
event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged.");
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED -> {
try { event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged."); }
catch (RuntimeException feedbackFailure) { report(event.getPlayer().getUniqueId(), feedbackFailure); }
}
default -> { /* Acceptance/loading is cosmetic and needs no gameplay callback. */ }
}
}
@@ -55,5 +96,13 @@ public final class SheetResourcePack implements Listener, AutoCloseable {
catch (RuntimeException ignored) { /* Diagnostics cannot make a cosmetic pack mandatory. */ }
}
@Override public void close() { closed = true; }
@Override public void close() {
closed = true;
offers.forEach((id, offer) -> {
var player = recipients.get(id);
if (player != null) try { player.removeResourcePack(offer.request().pack()); }
catch (RuntimeException failure) { report(id, failure); }
});
offers.clear(); recipients.clear(); events = null;
}
}
@@ -88,6 +88,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.party.CombatExperience combatExperience;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
private OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
@@ -371,6 +372,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
public void attachDungeons(games.dmg.fantasystick.dungeon.DungeonCommands commands){if(closed||dungeonCommands!=null)throw new IllegalStateException("Dungeons already attached or command closed");dungeonCommands=Objects.requireNonNull(commands);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachSheetResourcePackEvents(games.dmg.fantasystick.events.SheetResourcePackEventBridge events){
if(closed||sheetResourcePackSettings==null||sheetResourcePackEvents!=null)
throw new IllegalStateException("Sheet pack events unavailable");
sheetResourcePackEvents=Objects.requireNonNull(events);
}
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
OutOfCombatRecovery recovery) {
if (closed || outOfCombatRecoverySettings != null) throw new IllegalStateException("Out-of-combat recovery already attached or command closed");
@@ -1837,7 +1843,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){
if(sheetResourcePackSettings==null){configurationUsage(sender);return;}
String key=args[1]; var audit=sheetResourcePackEvents;
if(args.length==2){
var effective=sheetResourcePackSettings.value(key);
if(audit!=null)audit.read(audit.actor(sender),key,effective);
sender.sendMessage(key+"="+effective); return;
}
var actor=audit==null?null:audit.actor(sender);
var action=UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;
try{operation=sheetResourcePackSettings.set(key,args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-sheet-resource-pack",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
boolean applied=false;
if(failure==null){
var before=sheetResourcePackSettings.value(key);
applied=sheetResourcePackSettings.publish(saved);
var effective=sheetResourcePackSettings.value(key);
if(audit!=null&&applied&&!before.equals(effective))
audit.changed(actor,action,key,before,effective,saved);
if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers(),"config_change");
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");
else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1],before=partyEconomySettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(partyEvents!=null)partyEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(applied&&partyEvents!=null)partyEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
@@ -1615,6 +1615,7 @@ class FantasyStickLifecycleNativeTest {
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final List<net.kyori.adventure.title.Title> titles = new ArrayList<>();
final List<Object[]> resourcePacks = new ArrayList<>();
final List<java.util.UUID> removedResourcePacks = new ArrayList<>();
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
final List<List<org.bukkit.NamespacedKey>> recipeAwards = new ArrayList<>();
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
@@ -1720,6 +1721,7 @@ class FantasyStickLifecycleNativeTest {
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
case "showTitle" -> { titles.add((net.kyori.adventure.title.Title)args[0]); yield null; }
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
case "removeResourcePack" -> { removedResourcePacks.add((java.util.UUID)args[0]); yield null; }
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
case "discoverRecipes" -> {
var requested = ((java.util.Collection<?>) args[0]).stream()
@@ -0,0 +1,384 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Native optional delivery and actual status callbacks, not a cosmetic or request-only inference. */
class SheetResourcePackEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void startupNativeRequestAndRealAcceptedThenLoadedCallbacksExportDistinctEvents() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
assertEquals(1, player.resourcePacks.size());
var request = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(),
PlayerResourcePackStatusEvent.Status.ACCEPTED));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(4, entries.size(), "Native delivery/status/read must reach exported JSONL");
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).count());
assertEquals(2, entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).count());
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.read")).count());
}
@Test void acknowledgedRedeliveryUsesDistinctRequestIdentitySoLateOldStatusCannotClaimNewLoad() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player); f.plugin.setEnabled(true);
var first = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
var admin = new EquipmentAdminCommandNativeTest.Sender();
var url = "https://packs.example.test/sheet-v2.zip?secret=do-not-export";
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.URL_KEY, url});
for (int i = 0; i < 30 && player.resourcePacks.size() < 2; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
var second = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
assertNotEquals(first.id(), second.id(), "A status exposes only pack ID; each offer needs a distinct ID");
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, first.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, second.id(),
PlayerResourcePackStatusEvent.Status.DECLINED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertTrue(events().stream().noneMatch(e -> e.toString().contains("do-not-export")));
var requested = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals(2, requested.size());
var statuses = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).toList();
assertEquals(1, statuses.size(), "A late prior-request callback cannot claim current client load");
assertEquals("declined", statuses.getFirst().getAsJsonObject("data").get("status").getAsString());
}
@Test void reloadCannotAssignAnOldLifetimeStatusToANewStartupOffer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
var old = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
f.plugin.setEnabled(false);
f.plugin.setEnabled(true);
var current = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
assertNotEquals(old.id(), current.id(), "Old callbacks must never identify a new lifetime's offer");
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, old.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(2, events().stream().filter(e -> e.get("type").getAsString()
.endsWith("delivery_requested")).count());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("delivery_resolved")));
}
@Test void joinAndNativeOutcomesAreDistinctDeduplicatedAndNeverImplyAnUnobservedLoad() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(player.player,
(net.kyori.adventure.text.Component)null));
var request = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
for (var status : List.of(PlayerResourcePackStatusEvent.Status.ACCEPTED,
PlayerResourcePackStatusEvent.Status.DOWNLOADED,
PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,
PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD))
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(), status));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, java.util.UUID.randomUUID(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(4, entries.size());
var requested = entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals("join", requested.getFirst().getAsJsonObject("data").get("trigger").getAsString());
var statuses = entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).toList();
assertEquals(List.of("accepted", "downloaded", "failed_download"), statuses.stream()
.map(e -> e.getAsJsonObject("data").get("status").getAsString()).toList());
assertTrue(statuses.stream().allMatch(e -> e.getAsJsonObject("data")
.get("origin_delivery_action_id").getAsString().equals(requested.getFirst().getAsJsonObject("data")
.get("action_id").getAsString())));
}
@Test void authorizedPhysicalReadsAndRefusalsNeverLeakUrlSecretsOrClaimFailedChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", SheetResourcePackSettings.SHA1_KEY});
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
var reads = events(); assertEquals(2, reads.size());
assertEquals(admin.core.id.toString(), reads.getFirst().getAsJsonObject("data")
.get("player_id").getAsString());
assertEquals(admin.core.location.getX(), reads.getFirst().getAsJsonObject("data")
.getAsJsonObject("location").get("x").getAsDouble());
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"https://packs.example.test/other.zip?secret=do-not-export"});
sender.allowed = true;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"http://packs.example.test/invalid.zip"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.SHA1_KEY,
"not-a-hash"});
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"https://packs.example.test/other.zip?secret=do-not-export"});
for (int i = 0; i < 30 && !sender.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(sender.messages.getLast().contains("save failed"));
f.plugin.setEnabled(false);
}
assertEquals(2, events().size());
assertTrue(events().stream().noneMatch(e -> e.toString().contains("do-not-export")));
}
@Test void writerUnavailableRetainsOneAcknowledgedChangeForReplayAfterRestart() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
for (int i = 0; i < 30 && sender.messages.isEmpty(); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(sender.messages.getLast().startsWith("Saved "));
f.plugin.setEnabled(false);
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
assertEquals("a".repeat(40), changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void closedOldCallbackAndFailedFeedbackNeverFabricateClientLoadOrReverseSavedPair() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
CommandSender broken = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
return switch (name) {
case "hasPermission" -> true;
case "sendMessage" -> throw new IllegalStateException("feedback delivery failed");
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
f.adminCommand.execute(broken, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
for (int i = 0; i < 30 && !"a".repeat(40).equals(settings.sha1()); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
assertEquals("a".repeat(40), settings.sha1());
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(1, events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).count());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("delivery_resolved")));
}
@Test void nativeOfferAndFeedbackFailureRemainOptionalAndCannotPreventStartup() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
Player broken = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> {
if (name.equals("addResourcePack")) throw new IllegalStateException("native offer failed");
if (name.equals("sendMessage")) throw new IllegalStateException("feedback failed");
var arguments = args == null ? new Object[0] : args;
var matching = java.util.Arrays.stream(Player.class.getMethods())
.filter(method -> method.getName().equals(name) && method.getParameterCount() == arguments.length)
.filter(method -> java.util.stream.IntStream.range(0, arguments.length)
.allMatch(i -> arguments[i] == null || method.getParameterTypes()[i].isInstance(arguments[i])
|| method.getParameterTypes()[i].isPrimitive()))
.findFirst().orElseThrow();
try { return matching.invoke(player.player, arguments); }
catch (ReflectiveOperationException failed) { throw new IllegalStateException(failed); }
});
f.players.put(player.core.id, broken);
assertDoesNotThrow(() -> f.plugin.setEnabled(true));
for (int i = 0; i < 200 && events().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var requested = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals(1, requested.size());
assertEquals("native_offer_failed", requested.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("delivery_resolved")));
}
@Test void unavailableLocationIsExplicitlyQualifiedWithoutBlockingNativeOfferAndStatus() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = mock(Player.class);
when(player.isOnline()).thenReturn(true);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(player.getName()).thenReturn("LocationUnavailable");
when(player.getLocation()).thenThrow(new IllegalArgumentException("World unloaded"));
var delivery = (games.dmg.fantasystick.presentation.SheetResourcePack)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sheetResourcePack");
delivery.deliver(player);
var offered = org.mockito.ArgumentCaptor.forClass(java.util.UUID.class);
verify(player).addResourcePack(offered.capture(), anyString(), any(byte[].class), anyString(), eq(false));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player, offered.getValue(),
PlayerResourcePackStatusEvent.Status.DECLINED));
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(2, entries.size());
assertTrue(entries.stream().allMatch(e -> e.getAsJsonObject("data").has("location_status")
&& "unavailable".equals(e.getAsJsonObject("data").get("location_status").getAsString())));
assertTrue(entries.stream().allMatch(e -> !e.getAsJsonObject("data").has("location")));
}
@Test void failedAuditIntentCannotPreventOptionalDeliveryOrSaveBeforeRedelivery() throws Exception {
Files.writeString(data.resolve("sheet-resource-pack-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
assertEquals(1, player.resourcePacks.size());
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
for (int i = 0; i < 30 && player.resourcePacks.size() < 2; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertEquals(2, player.resourcePacks.size());
assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),
SheetResourcePackNativeTest.request(player.resourcePacks.getLast()).hash());
f.plugin.setEnabled(false);
}
assertTrue(events().isEmpty(), "The unavailable audit writer cannot manufacture exported evidence");
}
@Test void supersededSavedCallbacksNeverRedeliverOrRecordTheStalePair() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
f.awaitCharacterAndReceipt(player);
PartyBookNativeTest.drain(f);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
var callbacks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var command = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "admin");
var scheduler = command.getClass().getDeclaredField("nextTick");
scheduler.setAccessible(true);
var original = scheduler.get(command);
try {
scheduler.set(command, (java.util.function.Consumer<Runnable>)callbacks::add);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
var first = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "b".repeat(40)});
var second = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
} finally { scheduler.set(command, original); }
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
assertEquals("b".repeat(40), settings.sha1());
assertEquals(2, player.resourcePacks.size(), "Only the latest acknowledged policy redelivers");
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
assertEquals("b".repeat(40), changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void closeBeforeSavedCallbackCannotClaimUnpublishedConfigOrDeliverPack() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertEquals(1, player.resourcePacks.size());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
f.plugin.setEnabled(true);
assertEquals("a".repeat(40), ((SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings")).sha1());
f.plugin.setEnabled(false);
}
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
}
private List<JsonObject> events() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
var type = event.get("type").getAsString();
if (type.endsWith("resource_pack.delivery_requested")
|| type.endsWith("resource_pack.delivery_resolved")
|| (type.endsWith("config.read") || type.endsWith("config.changed"))
&& event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("sheet-resource-pack-"))
entries.add(event);
}
}
}
}
}
return entries;
}
}
@@ -2,7 +2,7 @@ package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;import java.nio.file.Path;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertNotNull(request.id(),"Every native offer has its own correlatable pack identity");assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}