This commit is contained in:
1 parent
4984ad26e7
commit
65a76b7fc4
14 files changed
+1251
-15
No files matched your search
@@ -187,6 +187,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge armorAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> armorAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> coinAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
|
||||
@@ -898,6 +900,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
|
||||
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
armorAuditEvents = new games.dmg.fantasystick.events.ArmorEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Armor event intent failed", failure));
|
||||
if (armorMarket != null) armorMarket.attachEvents(armorAuditEvents);
|
||||
admin.attachArmorEvents(armorAuditEvents);
|
||||
if (armorShop != null) armorShop.attachEvents(armorAuditEvents);
|
||||
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING,"Combat reward event intent failed",failure));combatExperience.attachEvents(combatRewardEvents);
|
||||
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
|
||||
@@ -1071,6 +1078,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
|
||||
if (replayPassivePotionEvents != null) replayPassivePotionEvents.replay();
|
||||
});
|
||||
var replayArmorEvents = armorAuditEvents;
|
||||
armorAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous armor event drain failed", failure);
|
||||
if (replayArmorEvents != null) replayArmorEvents.replay();
|
||||
});
|
||||
var replayCoinEvents = coinEvents;
|
||||
coinAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous coin event drain failed", failure);
|
||||
@@ -1174,6 +1186,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
|
||||
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
|
||||
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
|
||||
var closingArmorEvents = armorAuditEvents; armorAuditEvents = null;
|
||||
var closingCoinEvents = coinEvents;
|
||||
var closingCombatRewardEvents = combatRewardEvents;
|
||||
var closingHealthStealEvents = healthStealEvents; healthStealEvents = null;
|
||||
@@ -1544,6 +1557,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingPassivePotionEvents.close();
|
||||
passivePotionAuditDrain = closingPassivePotionEvents.drained();
|
||||
});
|
||||
if (closingArmorEvents != null) cleanup("armor event intent drain", () -> {
|
||||
var marketDrained = closingArmorMarket == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingArmorMarket.drained();
|
||||
var registrationsDrained = closingArmorers == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingArmorers.drained();
|
||||
armorAuditDrain = java.util.concurrent.CompletableFuture.allOf(marketDrained.toCompletableFuture(),
|
||||
registrationsDrained.toCompletableFuture()).handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Armor authority drain failed", failure);
|
||||
closingArmorEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingArmorEvents.drained());
|
||||
});
|
||||
if (closingCoinEvents != null) cleanup("coin event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorCatalog;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Failure-isolated immutable records from acknowledged armor authorities, never gameplay replay. */
|
||||
public final class ArmorEventBridge implements AutoCloseable {
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final java.time.Clock clock = java.time.Clock.systemUTC();
|
||||
public record Actor(String kind, UUID playerId, String playerName, EventInput.Context context) {
|
||||
public Actor(String kind, EventInput.Context context) {
|
||||
this(kind, context == null ? null : context.playerId(), context == null ? null : context.playerName(), context);
|
||||
}
|
||||
}
|
||||
private static void actorData(Map<String, Object> values, Actor actor) {
|
||||
values.put("actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
values.put("request_actor_kind", actor == null ? "unavailable" : actor.kind());
|
||||
values.put("actor_capture_quality", actor == null || actor.kind().equals("player") && actor.context() == null ? "unavailable" : "native");
|
||||
if (actor != null && actor.playerId() != null) values.put("request_player_id", actor.playerId().toString());
|
||||
if (actor != null && actor.playerName() != null) values.put("request_player_name", actor.playerName());
|
||||
}
|
||||
private final Set<UUID> catalogs = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public ArmorEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data, Consumer<Throwable> failures) {
|
||||
outbox = new FeatureEventOutbox(data, "armor-event-intents", Set.of("armor.catalog_refreshed", "armorer.placed", "armorer.removed", "armor.purchased", "config.read", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public void catalogRefreshed(ArmorCatalog catalog) {
|
||||
try {
|
||||
if (!catalogs.add(catalog.revision())) return;
|
||||
var products = new ArrayList<Map<String, String>>();
|
||||
catalog.products().entrySet().stream().sorted(Comparator.comparing(entry ->
|
||||
entry.getKey().slot().name() + "." + entry.getKey().tier().name())).forEach(entry -> {
|
||||
var product = entry.getKey(); var offer = entry.getValue();
|
||||
products.add(Map.of("product_id", product.slot().name().toLowerCase(Locale.ROOT) + "."
|
||||
+ product.tier().name().toLowerCase(Locale.ROOT), "price", offer.price().toString(),
|
||||
"stock", Integer.toString(offer.remaining())));
|
||||
});
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("actor_kind", "system"); values.put("catalog_revision", catalog.revision().toString());
|
||||
values.put("catalog_day", catalog.day().toString()); values.put("refreshed_at", catalog.refreshedAt().toString());
|
||||
values.put("refresh_reason", catalog.reason().name().toLowerCase(Locale.ROOT));
|
||||
values.put("next_midnight", catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString());
|
||||
values.put("products", java.util.List.copyOf(products)); values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + catalog.revision() + ":catalog-refreshed")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, catalog.revision(), catalog.refreshedAt(),
|
||||
"armor.catalog_refreshed", null, Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot interrupt the already-saved catalog. */ }
|
||||
}
|
||||
|
||||
public Actor actor(org.bukkit.command.CommandSender sender, games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
org.bukkit.Location input) {
|
||||
String kind = sender instanceof org.bukkit.entity.Player ? "player"
|
||||
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
|
||||
if (!(sender instanceof org.bukkit.entity.Player player)) return new Actor(kind, null);
|
||||
UUID id = null; String name = null; UUID character = null, session = null; String characterName = null;
|
||||
EventInput.Location location = null;
|
||||
try { id = player.getUniqueId(); } catch (RuntimeException ignored) { }
|
||||
try { name = player.getName(); } catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var active = id == null ? null : sessions.active(id).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) { character = record.id(); characterName = record.name(); session = active.session(); }
|
||||
} catch (RuntimeException ignored) { }
|
||||
try {
|
||||
var nativeLocation = input == null ? player.getLocation() : input;
|
||||
var world = nativeLocation.getWorld();
|
||||
if (world != null) location = new EventInput.Location(world.getUID(), world.getName(), nativeLocation.getX(),
|
||||
nativeLocation.getY(), nativeLocation.getZ(), clock.instant(), "native");
|
||||
} catch (RuntimeException ignored) { }
|
||||
try { return new Actor(kind, id, name, new EventInput.Context(id, name, character, characterName, session, location)); }
|
||||
catch (RuntimeException unavailable) { return new Actor(kind, id, name, null); }
|
||||
}
|
||||
|
||||
public void armorerChanged(String type, UUID operation, games.dmg.fantasystick.shop.FantasyArmorer shop,
|
||||
Actor actor, games.dmg.fantasystick.shop.FantasyArmorerController.Observed entities,
|
||||
String outcome) {
|
||||
try {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
actorData(values, actor);
|
||||
values.put("operation_id", operation.toString()); values.put("armorer_id", shop.id().toString());
|
||||
values.put("armorer_name", shop.name());
|
||||
values.put("revision_capture_quality", shop.revision() == null ? "unavailable" : "persisted");
|
||||
if (shop.revision() != null) values.put("armorer_revision", shop.revision().toString());
|
||||
values.put("world_id", shop.world().toString()); values.put("x", shop.x()); values.put("y", shop.y());
|
||||
values.put("z", shop.z()); values.put("yaw", (double) shop.yaw()); values.put("pitch", (double) shop.pitch());
|
||||
values.put("chunk_status", entities.chunkStatus()); values.put("entity_capture_quality", entities.quality());
|
||||
values.put("entity_ids", entities.ids().stream().map(UUID::toString).toList());
|
||||
if (entities.ids().size() == 1) values.put("entity_id", entities.ids().getFirst().toString());
|
||||
values.put("entity_outcome", outcome); values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, operation, clock.instant(), type,
|
||||
actor == null ? null : actor.context(), Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot undo native registration/reconciliation. */ }
|
||||
}
|
||||
|
||||
public void purchased(games.dmg.fantasystick.shop.ArmorMarket.PurchaseProof proof, UUID catalog,
|
||||
games.dmg.fantasystick.shop.FantasyArmorer shop, games.dmg.fantasystick.shop.ArmorProduct product,
|
||||
Actor actor, games.dmg.fantasystick.persistence.CharacterRecord before,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord after) {
|
||||
try {
|
||||
// Bind the audit to the real consume-once receipt, never a reconstructed operation or duplicate resolution.
|
||||
if (!after.rewardReceipts().contains(proof.operation()) || before.rewardReceipts().contains(proof.operation())) return;
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
actorData(values, actor);
|
||||
values.put("purchase_operation_id", proof.operation().toString()); values.put("receipt_id", proof.operation().toString());
|
||||
values.put("catalog_revision", catalog.toString()); values.put("armorer_id", shop.id().toString());
|
||||
values.put("revision_capture_quality", shop.revision() == null ? "unavailable" : "persisted");
|
||||
if (shop.revision() != null) values.put("armorer_revision", shop.revision().toString());
|
||||
values.put("product_id", (product.slot().name() + "." + product.tier().name()).toLowerCase(Locale.ROOT));
|
||||
values.put("slot", product.slot().name().toLowerCase(Locale.ROOT));
|
||||
values.put("previous_tier", before.gear().tier(product.slot()).name().toLowerCase(Locale.ROOT));
|
||||
values.put("new_tier", after.gear().tier(product.slot()).name().toLowerCase(Locale.ROOT));
|
||||
values.put("price", proof.price().toString()); values.put("balance_before", before.coins().toString());
|
||||
values.put("balance_after", after.coins().toString()); values.put("stock_before", Integer.toString(proof.stockBefore()));
|
||||
values.put("stock_after", Integer.toString(proof.stockAfter()));
|
||||
values.put("before_revision", before.revision().toString()); values.put("after_revision", after.revision().toString());
|
||||
if (actor != null && actor.context() != null && actor.context().sessionId() != null)
|
||||
values.put("session_id", actor.context().sessionId().toString());
|
||||
values.put("location_capture_quality", actor != null && actor.context() != null && actor.context().location() != null ? "native" : "unavailable");
|
||||
values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + proof.operation() + ":purchased").getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, proof.operation(), clock.instant(), "armor.purchased",
|
||||
actor == null ? null : actor.context(), Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot change saved coins, stock or native gear. */ }
|
||||
}
|
||||
|
||||
public void configRead(Actor actor, String key, String value) {
|
||||
configuration("config.read", actor, key, null, value);
|
||||
}
|
||||
public void configChanged(Actor actor, String key, String before, String after) {
|
||||
configuration("config.changed", actor, key, before, after);
|
||||
}
|
||||
private void configuration(String type, Actor actor, String key, String before, String after) {
|
||||
try {
|
||||
var operation = UUID.randomUUID(); var values = new LinkedHashMap<String, Object>();
|
||||
actorData(values, actor); values.put("key", key); values.put("after", after);
|
||||
if (before != null) values.put("before", before);
|
||||
values.put("application", "next_armor_restock"); values.put("application_scope", "next_catalog");
|
||||
values.put("outcome", "acknowledged");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, operation, clock.instant(), type,
|
||||
actor == null ? null : actor.context(), Map.copyOf(values)));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot read/write effective gameplay policy or restock. */ }
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import games.dmg.fantasystick.shop.FantasyArmorer;import java.io.*;import java.n
|
||||
/** Forced atomic desired-state adapter for Fantasy Armorer registrations. */
|
||||
public final class YamlFantasyArmorerRepository{
|
||||
private final Path file;public YamlFantasyArmorerRepository(Path data){file=data.toAbsolutePath().resolve("fantasy-armorers.yml");}
|
||||
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
|
||||
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch")),row.containsKey("registration-revision")?UUID.fromString(text(row,"registration-revision")):null));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
|
||||
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());if(value.revision()!=null)row.put("registration-revision",value.revision().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static void validate(List<FantasyArmorer> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Armorer identity");}private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException();}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge armorEvents;
|
||||
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
@@ -361,6 +362,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
|
||||
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
|
||||
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
|
||||
public void attachArmorEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
|
||||
if (closed || armorEvents != null) throw new IllegalStateException("Armor events already attached or closed");
|
||||
armorEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge events){if(closed||coinEvents!=null)throw new IllegalStateException("Coin events already attached or command closed");coinEvents=Objects.requireNonNull(events);}
|
||||
public void attachAbilityConfigEvents(games.dmg.fantasystick.events.AbilityConfigEventBridge events){if(closed||abilityConfigEvents!=null)throw new IllegalStateException("Ability config events already attached or command closed");abilityConfigEvents=Objects.requireNonNull(events);}
|
||||
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
|
||||
@@ -862,13 +867,77 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
else sender.sendMessage("Restocked all Fantasy Armorers with catalog "+catalog.revision()+"; the next automatic restock remains 00:00 UTC.");
|
||||
});}catch(RuntimeException rejected){report(null,"shop-restock-response",rejected);}});return;}
|
||||
if(args[1].equals("list")&&args.length==2){var values=armorers.all();if(values.isEmpty())sender.sendMessage("No Fantasy Armorers are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Fantasy Armorer":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
|
||||
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,saved,failure));return;}
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
|
||||
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();var auditActor=armorEvents==null?null:armorEvents.actor(sender,sessions,location);var auditOperation=UUID.randomUUID();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,auditOperation,auditActor,saved,failure));return;}
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}var auditActor=armorEvents==null?null:armorEvents.actor(sender,sessions,null);var auditOperation=UUID.randomUUID();armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,auditOperation,auditActor,removed,failure));return;}
|
||||
shopUsage(sender);
|
||||
}
|
||||
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
|
||||
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
|
||||
private void placeShopResponse(org.bukkit.entity.Player player, org.bukkit.Location location, UUID operation,
|
||||
games.dmg.fantasystick.events.ArmorEventBridge.Actor actor,
|
||||
games.dmg.fantasystick.shop.FantasyArmorer saved, Throwable failure) {
|
||||
if (failure != null) report(actor == null ? null : actor.playerId(), "shop-place", failure);
|
||||
if (closed) return;
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure != null) {
|
||||
if (player.isOnline()) player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int x = location.getBlockX() >> 4, z = location.getBlockZ() >> 4;
|
||||
if (location.getWorld().isChunkLoaded(x, z)) armorerController.reconcile(location.getWorld().getChunkAt(x, z));
|
||||
if (armorEvents != null) {
|
||||
var observed = armorerController.observed(saved);
|
||||
String outcome = !observed.quality().equals("native") ? "unavailable"
|
||||
: !observed.ids().isEmpty() ? "materialized" : observed.chunkStatus().equals("unloaded") ? "unloaded" : "not_materialized";
|
||||
armorEvents.armorerChanged("armorer.placed", operation, saved, actor, observed, outcome);
|
||||
}
|
||||
if (player.isOnline()) player.sendMessage("Placed Fantasy Armorer " + saved.id() + ".");
|
||||
} catch (RuntimeException entityFailure) {
|
||||
report(player.getUniqueId(), "shop-entity", entityFailure);
|
||||
armorers.remove(saved.id()).whenComplete((ignored, rollback) -> nextTick.accept(() -> {
|
||||
armorerController.removeLoaded(saved.id());
|
||||
if (player.isOnline()) player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
private void removeShopResponse(CommandSender sender, UUID id, UUID operation,
|
||||
games.dmg.fantasystick.events.ArmorEventBridge.Actor actor,
|
||||
java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed, Throwable failure) {
|
||||
if (failure != null) report(null, "shop-remove", failure);
|
||||
if (closed) return;
|
||||
nextTick.accept(() -> {
|
||||
if (failure != null) sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");
|
||||
else if (removed.isEmpty()) sender.sendMessage("Fantasy Armorer not found.");
|
||||
else try {
|
||||
var saved = removed.orElseThrow();
|
||||
var before = armorEvents == null ? null : armorerController.observed(saved);
|
||||
armorerController.removeLoaded(id);
|
||||
if (armorEvents != null) {
|
||||
var after = armorerController.observed(saved);
|
||||
String outcome = !before.quality().equals("native") || !after.quality().equals("native") ? "unavailable"
|
||||
: before.ids().isEmpty() ? "none_loaded" : before.ids().stream().anyMatch(after.ids()::contains) ? "not_removed" : "removed";
|
||||
armorEvents.armorerChanged("armorer.removed", operation, saved, actor, before, outcome);
|
||||
}
|
||||
sender.sendMessage("Removed Fantasy Armorer " + id + ".");
|
||||
} catch (RuntimeException entityFailure) {
|
||||
var value = removed.orElseThrow(); report(null, "shop-entity-remove", entityFailure);
|
||||
armorers.restore(value).whenComplete((restored, rollback) -> {
|
||||
if (rollback != null) report(null, "shop-remove-rollback", rollback);
|
||||
if (closed) return;
|
||||
nextTick.accept(() -> {
|
||||
if (rollback == null) {
|
||||
var world = server.getWorld(value.world());
|
||||
if (world != null && world.isChunkLoaded(((int) Math.floor(value.x())) >> 4, ((int) Math.floor(value.z())) >> 4))
|
||||
armorerController.reconcile(world.getChunkAt(((int) Math.floor(value.x())) >> 4, ((int) Math.floor(value.z())) >> 4));
|
||||
sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");
|
||||
} else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));if(rareAuditEvents!=null)rareAuditEvents.shopPlaced(saved,player.getUniqueId(),player.getName());player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);if(rareAuditEvents!=null)rareAuditEvents.shopRemoved(UUID.randomUUID(),removed.orElseThrow(),sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null,sender instanceof org.bukkit.entity.Player namedPlayer?namedPlayer.getName():null);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
|
||||
private static void rareShopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin rare-shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
@@ -1918,7 +1987,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
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);}});}
|
||||
|
||||
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
|
||||
private void gearConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
String value = gearSettings.value(key);
|
||||
if (armorEvents != null) armorEvents.configRead(armorEvents.actor(sender, sessions, null), key, value);
|
||||
sender.sendMessage(key + "=" + value + " (applies to the next armor restock only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;
|
||||
try { operation = gearSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");
|
||||
return;
|
||||
}
|
||||
var actor = armorEvents == null ? null : armorEvents.actor(sender, sessions, null);
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
nextTick.accept(() -> {
|
||||
String before = gearSettings.value(key);
|
||||
boolean applied = failure == null && gearSettings.publish(saved);
|
||||
if (applied && armorEvents != null) armorEvents.configChanged(actor, key, before, saved.value());
|
||||
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "=" + gearSettings.value(key) + ".");
|
||||
else sender.sendMessage("Saved " + key + "=" + saved.value() + " for the next automatic or administrative restock; the current catalog is unchanged.");
|
||||
});
|
||||
});
|
||||
}
|
||||
private static boolean potionConfig(String key){
|
||||
if(key.equals("rare-price-variance-percent"))return true;
|
||||
for(var product:games.dmg.fantasystick.shop.RareProduct.values())if(product.heals()){
|
||||
|
||||
@@ -11,8 +11,16 @@ import java.util.function.Supplier;
|
||||
/** Serialized durable authority for the one shared daily armor catalog. */
|
||||
public final class ArmorMarket implements AutoCloseable {
|
||||
public enum PurchaseStatus { APPLIED, SOLD_OUT, STALE, INSUFFICIENT, NOT_UPGRADE, UNAVAILABLE, FAILED }
|
||||
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
|
||||
public record PurchaseProof(UUID operation, java.math.BigInteger price, int stockBefore, int stockAfter) {}
|
||||
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog, PurchaseProof proof) {
|
||||
public PurchaseOutcome { Objects.requireNonNull(status); Objects.requireNonNull(record); Objects.requireNonNull(catalog); }
|
||||
public PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
|
||||
this(status, record, catalog, null);
|
||||
}
|
||||
}
|
||||
private static PurchaseProof proof(ArmorPurchaseOperationStore.Plan plan, ArmorCatalog catalog) {
|
||||
int after = catalog.offer(plan.product()).remaining();
|
||||
return new PurchaseProof(plan.id(), plan.price(), after + 1, after);
|
||||
}
|
||||
|
||||
private final ArmorCatalogRepository catalogs;
|
||||
@@ -26,6 +34,8 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
private volatile ArmorCatalog snapshot;
|
||||
private volatile boolean initialized;
|
||||
private volatile Consumer<ArmorCatalog> listener=ignored->{};
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge auditEvents;
|
||||
private ArmorCatalog newlyPublishedCatalog;
|
||||
private boolean closed,operationsClosing;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
@@ -40,6 +50,15 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void attachListener(Consumer<ArmorCatalog> listener){this.listener=Objects.requireNonNull(listener);}
|
||||
public synchronized void attachEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
|
||||
if (closed || auditEvents != null) throw new IllegalStateException("Armor market events already attached or closed");
|
||||
auditEvents = Objects.requireNonNull(events);
|
||||
if (newlyPublishedCatalog != null) auditEvents.catalogRefreshed(newlyPublishedCatalog);
|
||||
}
|
||||
private synchronized void refreshed(ArmorCatalog catalog) {
|
||||
newlyPublishedCatalog = catalog;
|
||||
if (auditEvents != null) auditEvents.catalogRefreshed(catalog);
|
||||
}
|
||||
public Optional<ArmorCatalog> snapshot(){return Optional.ofNullable(snapshot);}
|
||||
public Instant now(){return clock.instant();}
|
||||
public CompletionStage<Void> ready(){return readiness;}
|
||||
@@ -114,7 +133,8 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
var pruned=catalog.prune(plan.id());persist(pruned,false);
|
||||
var record=characters.find(plan.character()).join();
|
||||
return new PurchaseOutcome(transaction.state()==ArmorCatalog.TransactionState.CONSUMED
|
||||
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned);
|
||||
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned,
|
||||
transaction.state()==ArmorCatalog.TransactionState.CONSUMED ? proof(plan,pruned) : null);
|
||||
}
|
||||
CharacterStorage.GearPurchase purchase;
|
||||
try {
|
||||
@@ -133,7 +153,7 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
case STALE->PurchaseStatus.STALE;
|
||||
case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;
|
||||
};
|
||||
return new PurchaseOutcome(status,purchase.record(),catalog);
|
||||
return new PurchaseOutcome(status,purchase.record(),catalog,consumed ? proof(plan,catalog) : null);
|
||||
}
|
||||
|
||||
private ArmorCatalog ensureCurrent(ArmorCatalog.RefreshReason reason)throws IOException {
|
||||
@@ -148,7 +168,10 @@ public final class ArmorMarket implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void persist(ArmorCatalog catalog,boolean notify)throws IOException {
|
||||
catalogs.save(catalog);snapshot=catalog;if(notify)publish(catalog);
|
||||
var before = snapshot;
|
||||
catalogs.save(catalog);snapshot=catalog;
|
||||
if (notify && (before == null || !before.revision().equals(catalog.revision()))) refreshed(catalog);
|
||||
if(notify)publish(catalog);
|
||||
}
|
||||
|
||||
private void publish(ArmorCatalog catalog){if(!initialized)return;try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
|
||||
@@ -19,6 +19,11 @@ public final class ArmorShop {
|
||||
private final ArmorMarket market;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.ArmorEventBridge events;
|
||||
public void attachEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
|
||||
if (this.events != null) throw new IllegalStateException("Armor shop events already attached");
|
||||
this.events = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public ArmorShop(FantasyArmorerDirectory shops,EquipmentSessions sessions,ArmorMarket market,
|
||||
Consumer<Runnable> nextTick,Consumer<Throwable> failures){
|
||||
@@ -52,6 +57,7 @@ public final class ArmorShop {
|
||||
var context=context(player,shopId);
|
||||
if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}
|
||||
var captured=context.orElseThrow();
|
||||
var auditActor=events==null?null:events.actor(player,sessions,null);
|
||||
market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{
|
||||
if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}
|
||||
publishHeld(held,()->{
|
||||
@@ -64,8 +70,13 @@ public final class ArmorShop {
|
||||
case NOT_UPGRADE->Status.NOT_UPGRADE;case STALE->Status.STALE;
|
||||
case UNAVAILABLE,FAILED->Status.FAILED;
|
||||
};
|
||||
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())
|
||||
sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());
|
||||
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()) {
|
||||
var saved=purchase.record().orElseThrow();
|
||||
boolean acknowledged=sessions.acknowledgeProgress(captured.active(),captured.record().revision(),saved);
|
||||
if(acknowledged&&events!=null&&purchase.proof()!=null)
|
||||
events.purchased(purchase.proof(),purchase.catalog().revision(),captured.shop(),product,
|
||||
auditActor,captured.record(),saved);
|
||||
}
|
||||
result.accept(new Outcome(status,purchase.record()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import java.util.*;
|
||||
/** Durable desired location for one administrator-managed Fantasy Armorer. */
|
||||
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
|
||||
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch,UUID revision){public FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){this(id,name,world,x,y,z,yaw,pitch,null);}public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
|
||||
@@ -5,6 +5,15 @@ public final class FantasyArmorerController implements Listener,AutoCloseable{
|
||||
private final Plugin plugin;private final FantasyArmorerDirectory directory;private final BiConsumer<Player,UUID> interactions;private final NamespacedKey idKey,typeKey;private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private boolean closed;public FantasyArmorerController(Plugin plugin,FantasyArmorerDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"fantasy_armorer_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}public boolean tagged(Entity entity){return "fantasy_armorer".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,FantasyArmorer>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&§ion(value.x())==chunk.getX()&§ion(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var target:desired.values())if(!retained.contains(target.id()))spawn(target);}
|
||||
public record Observed(String chunkStatus,List<UUID> ids,String quality){public Observed{ids=List.copyOf(ids);}}
|
||||
public Observed observed(FantasyArmorer value){
|
||||
try{var world=plugin.getServer().getWorld(value.world());if(world==null)return new Observed("unavailable",List.of(),"unavailable");
|
||||
String chunk=world.isChunkLoaded(section(value.x()),section(value.z()))?"loaded":"unloaded";
|
||||
var entities=world.getEntities();if(entities==null)return new Observed(chunk,List.of(),"unavailable");
|
||||
var ids=new ArrayList<UUID>();for(var entity:entities)if(entity instanceof Villager&&tagged(entity)&&value.id().equals(id(entity)))ids.add(entity.getUniqueId());
|
||||
ids.sort(Comparator.comparing(UUID::toString));return new Observed(chunk,ids,"native");
|
||||
}catch(RuntimeException unavailable){return new Observed("unavailable",List.of(),"unavailable");}
|
||||
}
|
||||
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
|
||||
private void spawn(FantasyArmorer value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=section(value.x()),z=section(value.z());if(world.isChunkLoaded(x,z))world.spawn(location(value),Villager.class,villager->configure(villager,value));}private void configure(Villager villager,FantasyArmorer value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,"fantasy_armorer");data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Fantasy Armorer":value.name()));villager.setCustomNameVisible(true);}private Location location(FantasyArmorer value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}private static int section(double value){return((int)Math.floor(value))>>4;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
|
||||
|
||||
@@ -3,7 +3,7 @@ import games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository;import ja
|
||||
/** Serialized durable Fantasy Armorer desired state. */
|
||||
public final class FantasyArmorerDirectory implements AutoCloseable{
|
||||
private final YamlFantasyArmorerRepository repository;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armorer-persistence"));private volatile List<FantasyArmorer> snapshot;private final Map<UUID,Integer> leases=new HashMap<>();private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();public FantasyArmorerDirectory(YamlFantasyArmorerRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}public List<FantasyArmorer> all(){return snapshot;}public Optional<FantasyArmorer> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
|
||||
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(UUID.randomUUID(),name,world,x,y,z,yaw,pitch,UUID.randomUUID());var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
|
||||
public synchronized Optional<Lease> acquire(UUID id){if(closed||find(id).isEmpty())return Optional.empty();leases.merge(id,1,Integer::sum);return Optional.of(new Lease(id));}public final class Lease implements AutoCloseable{private final UUID id;private boolean released;private Lease(UUID id){this.id=id;}public FantasyArmorer shop(){return find(id).orElseThrow();}@Override public void close(){synchronized(FantasyArmorerDirectory.this){if(released)return;released=true;int remaining=leases.getOrDefault(id,0)-1;if(remaining<=0)leases.remove(id);else leases.put(id,remaining);}}}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> operation){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Armorer directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(operation.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.persistence.GearSettings;
|
||||
import games.dmg.fantasystick.shop.ArmorMarket;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorConfigurationEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void everyRegisteredGearReadExportsExactNextRestockPolicyNotDeniedOrInvalidCommands() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings");
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var denied = new EquipmentAdminCommandNativeTest.Sender(); denied.allowed = false;
|
||||
f.adminCommand.execute(denied.port, "fstickadmin", new String[]{"config", "gear-price-iron"});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "gear-price-variance-percent", "101"});
|
||||
assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
for (var key : GearSettings.keys().stream().sorted().toList()) {
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", key});
|
||||
assertTrue(sender.messages.stream().anyMatch(message -> message.startsWith(key + "=" + settings.value(key))));
|
||||
}
|
||||
for (int attempt = 0; attempt < 100 && rows("config.read").size() < GearSettings.keys().size(); attempt++) Thread.sleep(10);
|
||||
assertEquals(GearSettings.keys().size(), rows("config.read").size(), "Every actual registered gear read needs one owner JSONL record");
|
||||
for (var row : rows("config.read")) {
|
||||
var values = row.getAsJsonObject("data"); var key = values.get("key").getAsString();
|
||||
assertTrue(GearSettings.keys().contains(key)); assertEquals(settings.value(key), values.get("after").getAsString());
|
||||
assertEquals("next_armor_restock", values.get("application").getAsString()); assertEquals("console", values.get("actor_kind").getAsString());
|
||||
assertTrue(values.has("application_scope"), "Approved gear configuration events must include their next-catalog application scope");
|
||||
assertEquals("next_catalog", values.get("application_scope").getAsString());
|
||||
assertEquals("acknowledged", values.get("outcome").getAsString()); assertFalse(values.has("before"));
|
||||
}
|
||||
assertTrue(rows("config.changed").isEmpty()); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedPublishedGearChangesUseActualBeforeValueAndLockedSaveCannotClaimSuccessOrRestock() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings");
|
||||
var initial = settings.value(GearSettings.VARIANCE);
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "\nus054-unrelated: retain-me\n", java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "5"});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "6"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=6")));
|
||||
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
for (int attempt = 0; attempt < 100 && rows("config.changed").size() < 2; attempt++) Thread.sleep(10);
|
||||
assertEquals(2, rows("config.changed").size(), "Only acknowledged native policy publications need configuration change records");
|
||||
var first = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("5")).findFirst().orElseThrow();
|
||||
var second = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("6")).findFirst().orElseThrow();
|
||||
assertEquals(initial, first.get("before").getAsString());
|
||||
assertEquals("5", second.get("before").getAsString(), "Queued history starts at the prior actual native publication, not the request-time snapshot");
|
||||
assertEquals("next_armor_restock", second.get("application").getAsString());
|
||||
assertTrue(second.has("application_scope")); assertEquals("next_catalog", second.get("application_scope").getAsString());
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "7"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.contains("Armor market setting save failed; effective settings and the current catalog are unchanged."));
|
||||
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
assertEquals(2, rows("config.changed").size());
|
||||
}
|
||||
var yaml = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(data.resolve("config.yml").toFile());
|
||||
assertEquals(6, yaml.getInt(GearSettings.VARIANCE)); assertEquals("retain-me", yaml.getString("us054-unrelated"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(2, rows("config.changed").size());
|
||||
}
|
||||
|
||||
@Test void rejectedGearReadAndChangeHistoryReplaysWithoutApplyingOldPolicyRestockingOrRepeatingFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> hasStartupCatalog());
|
||||
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings"); var initial = settings.value(GearSettings.VARIANCE);
|
||||
((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "5"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=5")));
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "6"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=6")));
|
||||
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var intents = new java.util.ArrayList<java.util.Properties>();
|
||||
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
|
||||
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).toList()) {
|
||||
var properties = new java.util.Properties(); try (var input = java.nio.file.Files.newInputStream(path)) { properties.load(input); } intents.add(properties);
|
||||
}
|
||||
}
|
||||
assertEquals(4, intents.size()); assertTrue(rows("config.read").isEmpty()); assertTrue(rows("config.changed").isEmpty());
|
||||
var bytes = java.nio.file.Files.readAllBytes(data.resolve("config.yml")); var marketBytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
|
||||
var feedback = List.copyOf(sender.messages);
|
||||
f.plugin.setEnabled(true); market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
for (int attempt = 0; attempt < 100 && rows("config.read").size() + rows("config.changed").size() < 4; attempt++) Thread.sleep(10);
|
||||
assertEquals(2, rows("config.read").size()); assertEquals(2, rows("config.changed").size());
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("config.yml"))); assertArrayEquals(marketBytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")));
|
||||
assertEquals(feedback, sender.messages); assertEquals("6", ((GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings")).value(GearSettings.VARIANCE));
|
||||
var all = new java.util.ArrayList<>(rows("config.read")); all.addAll(rows("config.changed"));
|
||||
for (var intent : intents) {
|
||||
var replay = all.stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow();
|
||||
assertEquals(intent.getProperty("time"), replay.get("time").getAsString()); assertEquals(intent.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
|
||||
}
|
||||
assertTrue(rows("config.read").stream().anyMatch(row -> row.getAsJsonObject("data").get("after").getAsString().equals(initial)));
|
||||
var changed = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("6")).findFirst().orElseThrow();
|
||||
assertEquals("5", changed.get("before").getAsString()); assertEquals("next_armor_restock", changed.get("application").getAsString());
|
||||
assertEquals("next_catalog", changed.get("application_scope").getAsString());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
private boolean hasStartupCatalog() {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString().endsWith("armor.catalog_refreshed")); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse actual armor exports", failure); }
|
||||
}
|
||||
|
||||
private List<JsonObject> rows(String type) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(type))
|
||||
.filter(row -> row.getAsJsonObject("data").has("key")
|
||||
&& row.getAsJsonObject("data").get("key").getAsString().startsWith("gear-")).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.GearSlot;
|
||||
import games.dmg.fantasystick.persistence.GearTier;
|
||||
import games.dmg.fantasystick.shop.ArmorCatalog;
|
||||
import games.dmg.fantasystick.shop.ArmorMarket;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void startupAndRegisteredRestockExportOneCompleteSharedCatalogButRetainedRestartDoesNotRefresh() throws Exception {
|
||||
ArmorCatalog original, replacement;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
original = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(18, original.products().size());
|
||||
for (int attempt = 0; attempt < 100 && catalogs().isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, catalogs().size(), "A saved complete shared armor catalog must reach its owner JSONL record");
|
||||
verifyCatalog(original, catalogs().getFirst());
|
||||
assertEquals(original, market.current().toCompletableFuture().get());
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.contains("Restocked all Fantasy Armorers"))
|
||||
&& catalogs().size() == 2);
|
||||
replacement = market.current().toCompletableFuture().get();
|
||||
assertNotEquals(original.revision(), replacement.revision());
|
||||
verifyCatalog(replacement, catalogs().stream().filter(row -> row.getAsJsonObject("data")
|
||||
.get("catalog_revision").getAsString().equals(replacement.revision().toString())).findFirst().orElseThrow());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var restarted = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
assertEquals(replacement, restarted.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertEquals(2, catalogs().size(), "A retained-current restart/read cannot invent a new refresh");
|
||||
}
|
||||
|
||||
@Test void rejectedRegisteredRestockReplaysOriginalCatalogWithoutRestockingOrRepeatingFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var initial = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.contains("Restocked all Fantasy Armorers")));
|
||||
var committed = market.current().toCompletableFuture().get();
|
||||
assertNotEquals(initial.revision(), committed.revision());
|
||||
var feedback = List.copyOf(sender.messages);
|
||||
var bytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var pending = new java.util.Properties();
|
||||
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
|
||||
var retained = files.filter(path -> path.getFileName().toString().endsWith(".pending")).toList();
|
||||
assertEquals(1, retained.size());
|
||||
try (var input = java.nio.file.Files.newInputStream(retained.getFirst())) { pending.load(input); }
|
||||
}
|
||||
assertEquals(1, catalogs().size(), "Rejected delivery is not an exported restock record");
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 2);
|
||||
var restarted = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
assertEquals(committed, restarted.current().toCompletableFuture().get());
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")), "Replay cannot restock or rewrite the market");
|
||||
assertEquals(feedback, sender.messages, "Replay cannot deliver administration feedback again");
|
||||
var replay = catalogs().stream().filter(row -> row.get("id").getAsString().equals(pending.getProperty("id")))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(pending.getProperty("time"), replay.get("time").getAsString());
|
||||
assertEquals(pending.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
|
||||
verifyCatalog(committed, replay);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(2, catalogs().size());
|
||||
}
|
||||
|
||||
@Test void registeredPlacementAndRemovalExportActualSavedRevisionAndLoadedEntity() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(admin.core.id, admin.player); doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
|
||||
var anchor = admin.core.location.clone();
|
||||
int x = anchor.getBlockX() >> 4, z = anchor.getBlockZ() >> 4;
|
||||
var npc = mock(org.bukkit.entity.Villager.class); var entityId = java.util.UUID.randomUUID();
|
||||
when(npc.getUniqueId()).thenReturn(entityId); when(npc.getLocation()).thenReturn(anchor);
|
||||
var tags = new java.util.HashMap<org.bukkit.NamespacedKey, String>();
|
||||
var pdc = mock(org.bukkit.persistence.PersistentDataContainer.class);
|
||||
when(npc.getPersistentDataContainer()).thenReturn(pdc);
|
||||
doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(pdc)
|
||||
.set(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING), anyString());
|
||||
when(pdc.get(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING)))
|
||||
.thenAnswer(call -> tags.get(call.getArgument(0)));
|
||||
var present = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var chunk = mock(org.bukkit.Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(admin.world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z);
|
||||
when(chunk.getEntities()).thenAnswer(call -> present.get() ? new org.bukkit.entity.Entity[]{npc} : new org.bukkit.entity.Entity[0]);
|
||||
when(admin.world.getEntities()).thenAnswer(call -> present.get() ? List.of(npc) : List.of());
|
||||
when(admin.world.isChunkLoaded(x, z)).thenReturn(true); when(admin.world.getChunkAt(x, z)).thenReturn(chunk);
|
||||
doAnswer(call -> { java.util.function.Consumer<org.bukkit.entity.Villager> configure = call.getArgument(2);
|
||||
configure.accept(npc); present.set(true); return npc; }).when(admin.world)
|
||||
.spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
doAnswer(call -> { present.set(false); return null; }).when(npc).remove();
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"shop", "place", "Revision Forge"});
|
||||
admin.core.location.add(20, 0, 20); // Audit must retain registered input, not recapture the moved administrator.
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && present.get());
|
||||
var shop = directory.all().getFirst();
|
||||
assertNotNull(shop.revision()); assertEquals(List.of(shop), new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load());
|
||||
for (int attempt = 0; attempt < 100 && rows("armorer.placed").isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, rows("armorer.placed").size(), "Durable registration and actual native materialization need an owner record");
|
||||
var placed = rows("armorer.placed").getFirst(); var values = placed.getAsJsonObject("data");
|
||||
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString());
|
||||
assertEquals(shop.revision().toString(), values.get("armorer_revision").getAsString());
|
||||
assertEquals("persisted", values.get("revision_capture_quality").getAsString());
|
||||
assertEquals(entityId.toString(), values.get("entity_id").getAsString());
|
||||
assertEquals("materialized", values.get("entity_outcome").getAsString());
|
||||
assertEquals(admin.core.id.toString(), placed.get("playerid").getAsString());
|
||||
assertEquals(shop.world().toString(), values.get("world_id").getAsString());
|
||||
assertEquals(anchor.getX(), values.get("x").getAsDouble()); assertEquals(anchor.getY(), values.get("y").getAsDouble());
|
||||
assertEquals(anchor.getZ(), values.get("z").getAsDouble()); assertEquals(anchor.getYaw(), values.get("yaw").getAsDouble());
|
||||
assertEquals(anchor.getPitch(), values.get("pitch").getAsDouble());
|
||||
f.dispatcher.accept(new org.bukkit.event.world.ChunkLoadEvent(chunk, false));
|
||||
verify(admin.world, times(1)).spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
assertEquals(1, rows("armorer.placed").size(), "Loaded-chunk reconciliation is not another durable registration");
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", shop.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && !present.get() && rows("armorer.removed").size() == 1);
|
||||
var removed = rows("armorer.removed").getFirst().getAsJsonObject("data");
|
||||
assertEquals(shop.revision().toString(), removed.get("armorer_revision").getAsString());
|
||||
assertEquals(entityId.toString(), removed.get("entity_id").getAsString());
|
||||
assertEquals("removed", removed.get("entity_outcome").getAsString());
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", shop.id().toString()});
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("armorer.placed").size()); assertEquals(1, rows("armorer.removed").size());
|
||||
}
|
||||
|
||||
@Test void registeredShopPurchaseExportsExactStockCoinsTierAndReceiptNotDuplicateClicksOrRendering() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var ports = npc(f); var player = ports.player();
|
||||
var old = player.core.record; var balance = java.math.BigInteger.ONE.shiftLeft(100);
|
||||
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), balance, true,
|
||||
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision());
|
||||
f.activate(player);
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Purchase Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
|
||||
var shop = directory.all().getFirst();
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var before = storage.find(old.id()).get(); assertTrue(before.isPresent()); var previous = before.orElseThrow();
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var catalog = market.current().toCompletableFuture().get();
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON);
|
||||
var offer = catalog.offer(product);
|
||||
var session = ((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(player.core.id).orElseThrow().session();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty());
|
||||
var menu = player.inventories.getLast();
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(menu); when(view.getBottomInventory()).thenReturn(player.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(player.player); when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);
|
||||
when(view.getItem(27)).thenReturn(menu.getItem(27)); when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27);
|
||||
var click = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
f.dispatcher.accept(click); assertTrue(click.isCancelled());
|
||||
ReskillEventsNativeTest.await(f, () -> storage.find(old.id()).join().orElseThrow().gear().chestplate() == GearTier.IRON
|
||||
&& player.core.inventory.getChestplate() != null && player.core.inventory.getChestplate().getType() == org.bukkit.Material.IRON_CHESTPLATE);
|
||||
var saved = storage.find(old.id()).get().orElseThrow();
|
||||
assertEquals(balance.subtract(offer.price()), saved.coins()); assertTrue(player.core.inventory.getChestplate().getItemMeta().isUnbreakable());
|
||||
var afterCatalog = market.current().toCompletableFuture().get();
|
||||
assertEquals(offer.remaining() - 1, afterCatalog.offer(product).remaining());
|
||||
var receipts = new java.util.HashSet<>(saved.rewardReceipts()); receipts.removeAll(previous.rewardReceipts()); assertEquals(1, receipts.size());
|
||||
for (int attempt = 0; attempt < 100 && rows("armor.purchased").isEmpty(); attempt++) Thread.sleep(10);
|
||||
assertEquals(1, rows("armor.purchased").size(), "Acknowledged stock, coin and gear mutation must reach one purchase owner record");
|
||||
var row = rows("armor.purchased").getFirst(); var values = row.getAsJsonObject("data");
|
||||
assertEquals(receipts.iterator().next().toString(), values.get("receipt_id").getAsString());
|
||||
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString());
|
||||
assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
|
||||
assertEquals(player.core.id.toString(), row.get("playerid").getAsString()); assertEquals(old.id().toString(), row.get("characterid").getAsString());
|
||||
assertEquals(session.toString(), values.get("session_id").getAsString());
|
||||
assertEquals("chestplate.iron", values.get("product_id").getAsString());
|
||||
assertEquals("none", values.get("previous_tier").getAsString()); assertEquals("iron", values.get("new_tier").getAsString());
|
||||
assertEquals(offer.price().toString(), values.get("price").getAsString());
|
||||
assertEquals(balance.toString(), values.get("balance_before").getAsString()); assertEquals(saved.coins().toString(), values.get("balance_after").getAsString());
|
||||
assertEquals(Integer.toString(offer.remaining()), values.get("stock_before").getAsString());
|
||||
assertEquals(Integer.toString(offer.remaining() - 1), values.get("stock_after").getAsString());
|
||||
assertEquals(previous.revision().toString(), values.get("before_revision").getAsString()); assertEquals(saved.revision().toString(), values.get("after_revision").getAsString());
|
||||
assertEquals(ports.anchor().getX(), values.getAsJsonObject("location").get("x").getAsDouble());
|
||||
var duplicate = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
f.dispatcher.accept(duplicate); assertTrue(duplicate.isCancelled()); market.current().toCompletableFuture().get(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(saved, storage.find(old.id()).get().orElseThrow());
|
||||
PartyBookNativeTest.open(f, player); assertEquals(1, rows("armor.purchased").size());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("armor.purchased").size());
|
||||
}
|
||||
|
||||
@Test void realSharedMarketMidnightPublishesOneCompleteScheduledCatalogNotPerProductRecords() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
|
||||
var initial = market.current().toCompletableFuture().get();
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
|
||||
var field = ArmorMarket.class.getDeclaredField("clock"); field.setAccessible(true);
|
||||
field.set(market, java.time.Clock.fixed(initial.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant(), java.time.ZoneOffset.UTC));
|
||||
var next = market.current().toCompletableFuture().get();
|
||||
assertEquals(initial.day().plusDays(1), next.day()); assertNotEquals(initial.revision(), next.revision());
|
||||
assertEquals(ArmorCatalog.RefreshReason.SCHEDULED, next.reason());
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 2);
|
||||
verifyCatalog(next, catalogs().stream().filter(row -> row.getAsJsonObject("data").get("catalog_revision").getAsString().equals(next.revision().toString())).findFirst().orElseThrow());
|
||||
assertEquals(next, market.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(2, catalogs().size());
|
||||
}
|
||||
|
||||
@Test void rejectedRealPurchaseReplaysExactReceiptOfflineWithoutChargingEquippingRestockingOrFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var ports = npc(f); var player = ports.player(); var old = player.core.record;
|
||||
var balance = java.math.BigInteger.ONE.shiftLeft(100);
|
||||
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), balance, true,
|
||||
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision()); f.activate(player);
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Offline Receipt Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get() && rows("armorer.placed").size() == 1 && catalogs().size() == 1);
|
||||
var shop = directory.all().getFirst();
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON); var offer = catalog.offer(product);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var previous = storage.find(old.id()).get().orElseThrow();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty()); var menu = player.inventories.getLast();
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class); when(view.getTopInventory()).thenReturn(menu);
|
||||
when(view.getBottomInventory()).thenReturn(player.core.inventory); when(view.getPlayer()).thenReturn(player.player);
|
||||
when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); when(view.getItem(27)).thenReturn(menu.getItem(27));
|
||||
when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27);
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Purchased Iron Chestplate.")
|
||||
&& player.core.inventory.getChestplate() != null && player.core.inventory.getChestplate().getType() == org.bukkit.Material.IRON_CHESTPLATE);
|
||||
var saved = storage.find(old.id()).get().orElseThrow(); assertEquals(balance.subtract(offer.price()), saved.coins());
|
||||
assertEquals(GearTier.IRON, saved.gear().chestplate()); assertEquals(offer.remaining() - 1, market.current().toCompletableFuture().get().offer(product).remaining());
|
||||
var receipts = new java.util.HashSet<>(saved.rewardReceipts()); receipts.removeAll(previous.rewardReceipts()); assertEquals(1, receipts.size());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var pending = new java.util.Properties();
|
||||
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
|
||||
var intents = files.filter(path -> path.getFileName().toString().endsWith(".pending")).toList(); assertEquals(1, intents.size());
|
||||
try (var input = java.nio.file.Files.newInputStream(intents.getFirst())) { pending.load(input); }
|
||||
}
|
||||
assertTrue(rows("armor.purchased").isEmpty());
|
||||
var marketBytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
|
||||
var registrationBytes = java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
var character = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow();
|
||||
var feedback = List.copyOf(player.messages); var inventory = player.core.snapshot();
|
||||
f.players.clear(); player.core.online = false; clearInvocations(player.player); clearInvocations(player.world); clearInvocations(ports.entity());
|
||||
f.plugin.setEnabled(true); ready(f); ReskillEventsNativeTest.await(f, () -> rows("armor.purchased").size() == 1);
|
||||
assertEquals(character, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow());
|
||||
assertArrayEquals(marketBytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")));
|
||||
assertArrayEquals(registrationBytes, java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
|
||||
assertArrayEquals(inventory, player.core.snapshot()); assertEquals(feedback, player.messages);
|
||||
verifyNoInteractions(player.player, player.world, ports.entity());
|
||||
var row = rows("armor.purchased").getFirst(); var values = row.getAsJsonObject("data");
|
||||
assertEquals(pending.getProperty("id"), row.get("id").getAsString()); assertEquals(pending.getProperty("time"), row.get("time").getAsString());
|
||||
assertEquals(pending.getProperty("action"), values.get("action_id").getAsString());
|
||||
assertEquals(receipts.iterator().next().toString(), values.get("receipt_id").getAsString());
|
||||
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString()); assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
|
||||
assertEquals(balance.toString(), values.get("balance_before").getAsString()); assertEquals(saved.coins().toString(), values.get("balance_after").getAsString());
|
||||
assertEquals(previous.revision().toString(), values.get("before_revision").getAsString()); assertEquals(saved.revision().toString(), values.get("after_revision").getAsString());
|
||||
assertEquals("none", values.get("previous_tier").getAsString()); assertEquals("iron", values.get("new_tier").getAsString());
|
||||
assertEquals(Integer.toString(offer.remaining()), values.get("stock_before").getAsString()); assertEquals(Integer.toString(offer.remaining()-1), values.get("stock_after").getAsString());
|
||||
f.players.put(player.core.id, player.player); player.core.online = true;
|
||||
f.reenter(player); // Restored markers need a fresh slot observation, not another unchanged-marker request.
|
||||
assertEquals(org.bukkit.Material.IRON_CHESTPLATE, player.core.inventory.getChestplate().getType());
|
||||
assertTrue(player.core.inventory.getChestplate().getItemMeta().isUnbreakable());
|
||||
assertEquals(saved.coins(), new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow().coins());
|
||||
assertEquals(1, rows("armor.purchased").size(), "Reloading permanent native gear is not another purchase");
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertEquals(1, rows("armor.purchased").size());
|
||||
}
|
||||
|
||||
@Test void registeredPlacementSaveAndNativeSpawnFailuresCannotExportSuccessOrLeaveRegistration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player();
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
java.nio.file.Files.createDirectory(data.resolve("fantasy-armorers.yml"));
|
||||
java.nio.file.Files.writeString(data.resolve("fantasy-armorers.yml/block-save"), "nonempty directory must not be replaced");
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Unsaved Forge"});
|
||||
try { ReskillEventsNativeTest.await(f, () -> player.messages.contains("Fantasy Armorer placement failed; no registration or entity changed.")); }
|
||||
catch (AssertionError failure) { throw new AssertionError("Expected failed save: messages=" + player.messages + "; registrations=" + directory.all(), failure); }
|
||||
assertTrue(directory.all().isEmpty()); assertFalse(ports.present().get()); assertTrue(rows("armorer.placed").isEmpty());
|
||||
verify(player.world, never()).spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
java.nio.file.Files.delete(data.resolve("fantasy-armorers.yml/block-save"));
|
||||
java.nio.file.Files.delete(data.resolve("fantasy-armorers.yml")); player.messages.clear();
|
||||
doThrow(new IllegalStateException("Injected native Armorer spawn failure")).when(player.world).spawn(any(org.bukkit.Location.class),
|
||||
eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Unmaterialized Forge"});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty()
|
||||
&& player.messages.contains("Fantasy Armorer placement failed; no registration or entity changed."));
|
||||
assertTrue(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load().isEmpty());
|
||||
assertFalse(ports.present().get()); assertTrue(rows("armorer.placed").isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(rows("armorer.placed").isEmpty());
|
||||
}
|
||||
|
||||
@Test void registeredNativeRemovalFailureRestoresExactRevisionAndCannotExportRemovedSuccess() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player();
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Rollback Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get() && rows("armorer.placed").size() == 1);
|
||||
var saved = directory.all().getFirst(); var bytes = java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
doThrow(new IllegalStateException("Injected native Armorer removal failure")).when(ports.entity()).remove();
|
||||
try {
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "remove", saved.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().equals(List.of(saved))
|
||||
&& player.messages.contains("Fantasy Armorer removal failed; registration and entity are unchanged."));
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
|
||||
assertTrue(ports.present().get()); assertEquals(saved.revision(), directory.all().getFirst().revision());
|
||||
assertTrue(rows("armorer.removed").isEmpty()); assertEquals(1, rows("armorer.placed").size());
|
||||
} finally { doAnswer(call -> { ports.present().set(false); return null; }).when(ports.entity()).remove(); }
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(rows("armorer.removed").isEmpty());
|
||||
}
|
||||
|
||||
@Test void realConcurrentViewsCannotOversellOrClaimCancelledSoldOutStaleAndClosedPurchasesWithFeedbackOff() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var first = ports.player();
|
||||
var second = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(second.core.id, second.player);
|
||||
second.core.location.setWorld(first.world);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
for (var key : List.of("gear-stock-iron-min", "gear-stock-iron-max")) {
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", key, "1"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + key + "=1")));
|
||||
}
|
||||
f.adminCommand.execute(first.player, "fstickadmin", new String[]{"shop", "place", "Shared Last Item Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON); assertEquals(1, catalog.offer(product).remaining());
|
||||
// Ordinary/native-cancelled interactions are not purchase authorization or another menu.
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
assertTrue(first.inventories.isEmpty()); assertTrue(rows("armor.purchased").isEmpty());
|
||||
var balance = java.math.BigInteger.ONE.shiftLeft(100); fund(first, balance); fund(second, balance); f.activate(first); f.activate(second);
|
||||
f.feedbackCommand.execute(first.player, "fstickfeedback", new String[]{"off"});
|
||||
var cancelled = new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
cancelled.setCancelled(true); f.dispatcher.accept(cancelled); assertTrue(first.inventories.isEmpty());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(second.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
ReskillEventsNativeTest.await(f, () -> !first.inventories.isEmpty() && !second.inventories.isEmpty());
|
||||
var firstView = purchaseView(first, first.inventories.getLast()); var secondView = purchaseView(second, second.inventories.getLast());
|
||||
var veto = purchaseClick(firstView); veto.setCancelled(true); f.dispatcher.accept(veto); assertTrue(rows("armor.purchased").isEmpty());
|
||||
f.dispatcher.accept(purchaseClick(firstView)); f.dispatcher.accept(purchaseClick(secondView));
|
||||
ReskillEventsNativeTest.await(f, () -> first.messages.contains("Purchased Iron Chestplate.") && second.messages.contains("Armor purchase refused: sold out.") && rows("armor.purchased").size() == 1);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var winner = storage.find(first.core.record.id()).get().orElseThrow(); var loser = storage.find(second.core.record.id()).get().orElseThrow();
|
||||
assertEquals(balance.subtract(catalog.offer(product).price()), winner.coins()); assertEquals(GearTier.IRON, winner.gear().chestplate());
|
||||
assertEquals(balance, loser.coins()); assertEquals(GearTier.NONE, loser.gear().chestplate()); assertEquals(0, market.current().toCompletableFuture().get().offer(product).remaining());
|
||||
assertEquals(first.core.id.toString(), rows("armor.purchased").getFirst().get("playerid").getAsString());
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(firstView));
|
||||
assertEquals(winner, storage.find(first.core.record.id()).get().orElseThrow()); assertEquals(1, rows("armor.purchased").size());
|
||||
sender.messages.clear(); f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
|
||||
var current = market.current().toCompletableFuture().get();
|
||||
f.dispatcher.accept(purchaseClick(secondView));
|
||||
ReskillEventsNativeTest.await(f, () -> second.messages.contains("Armor purchase refused: stale."));
|
||||
assertEquals(loser, storage.find(second.core.record.id()).get().orElseThrow()); assertEquals(current, market.current().toCompletableFuture().get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, rows("armor.purchased").size());
|
||||
}
|
||||
@Test void failedRegisteredCatalogSaveCannotRefreshOrMutateAnySharedOffer() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var original = market.current().toCompletableFuture().get();
|
||||
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
|
||||
var path = data.resolve("armor-catalog.yml"); var backup = data.resolve("armor-catalog.saved-test");
|
||||
var bytes = java.nio.file.Files.readAllBytes(path); java.nio.file.Files.move(path, backup); java.nio.file.Files.createDirectory(path);
|
||||
java.nio.file.Files.writeString(path.resolve("block-save"), "nonreplaceable save target");
|
||||
try {
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
|
||||
ReskillEventsNativeTest.await(f, () -> sender.messages.contains("Fantasy Armorer restock failed; the prior catalog is unchanged."));
|
||||
assertEquals(original, market.current().toCompletableFuture().get()); assertEquals(1, catalogs().size());
|
||||
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(backup));
|
||||
} finally { java.nio.file.Files.delete(path.resolve("block-save")); java.nio.file.Files.delete(path); java.nio.file.Files.move(backup, path); }
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(1, catalogs().size());
|
||||
}
|
||||
|
||||
@Test void realPlanAndAtomicCoinGearSaveFailuresCannotClaimPurchaseAndRetainRecoverablePlan() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player(); fund(player, java.math.BigInteger.ONE.shiftLeft(100)); f.activate(player);
|
||||
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Store Fault Forge"});
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var before = storage.find(player.core.record.id()).get().orElseThrow();
|
||||
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty()); var view = purchaseView(player, player.inventories.getLast());
|
||||
var nativeBefore = player.core.snapshot();
|
||||
java.nio.file.Files.writeString(data.resolve("armor-purchase-operations"), "Block required operation directory");
|
||||
try {
|
||||
f.dispatcher.accept(purchaseClick(view));
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Armor purchase refused: failed."));
|
||||
assertEquals(before, storage.find(before.id()).get().orElseThrow()); assertEquals(catalog, market.current().toCompletableFuture().get());
|
||||
assertArrayEquals(nativeBefore, player.core.snapshot()); assertTrue(rows("armor.purchased").isEmpty());
|
||||
} finally { java.nio.file.Files.delete(data.resolve("armor-purchase-operations")); }
|
||||
player.messages.clear();
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); f.dispatcher.accept(purchaseClick(view));
|
||||
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Armor purchase refused: failed."));
|
||||
assertEquals(before, storage.find(before.id()).get().orElseThrow()); assertArrayEquals(nativeBefore, player.core.snapshot()); assertTrue(rows("armor.purchased").isEmpty());
|
||||
var reserved = market.current().toCompletableFuture().get(); assertEquals(catalog.revision(), reserved.revision());
|
||||
assertEquals(1, reserved.transactions().size()); assertEquals(catalog.offer(product).remaining()-1, reserved.offer(product).remaining());
|
||||
var operation = reserved.transactions().keySet().iterator().next();
|
||||
assertEquals(ArmorCatalog.TransactionState.RESERVED, reserved.transactions().get(operation).state());
|
||||
try (var files = java.nio.file.Files.list(data.resolve("armor-purchase-operations"))) {
|
||||
var plans = files.filter(path -> path.getFileName().toString().endsWith(".yml")).toList(); assertEquals(1, plans.size());
|
||||
var plan = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(plans.getFirst().toFile());
|
||||
assertEquals(operation.toString(), plan.getString("operation-id")); assertEquals(before.id().toString(), plan.getString("character"));
|
||||
assertEquals(before.revision().toString(), plan.getString("expected-character-revision"));
|
||||
assertEquals(catalog.offer(product).price().toString(), plan.getString("price"));
|
||||
}
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(rows("armor.purchased").isEmpty());
|
||||
}
|
||||
|
||||
private void fund(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger balance) throws Exception {
|
||||
var old = actor.core.record;
|
||||
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), balance, true, old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision());
|
||||
}
|
||||
private static org.bukkit.inventory.InventoryView purchaseView(FantasyStickLifecycleNativeTest.Actor actor, org.bukkit.inventory.Inventory menu) {
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class); when(view.getTopInventory()).thenReturn(menu); when(view.getBottomInventory()).thenReturn(actor.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(actor.player); when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);
|
||||
when(view.getItem(27)).thenReturn(menu.getItem(27)); when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27); return view;
|
||||
}
|
||||
private static org.bukkit.event.inventory.InventoryClickEvent purchaseClick(org.bukkit.inventory.InventoryView view) {
|
||||
return new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 27,
|
||||
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
}
|
||||
|
||||
private record Npc(FantasyStickLifecycleNativeTest.Actor player, org.bukkit.entity.Villager entity,
|
||||
org.bukkit.Location anchor, java.util.concurrent.atomic.AtomicBoolean present) {}
|
||||
private Npc npc(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(player.core.id, player.player); doReturn(true).when(player.player).hasPermission("fantasystick.admin");
|
||||
var anchor = player.core.location.clone(); int x = anchor.getBlockX() >> 4, z = anchor.getBlockZ() >> 4;
|
||||
var entity = mock(org.bukkit.entity.Villager.class); var present = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
when(entity.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(entity.getLocation()).thenReturn(anchor);
|
||||
when(entity.getType()).thenReturn(org.bukkit.entity.EntityType.VILLAGER);
|
||||
var tags = new java.util.HashMap<org.bukkit.NamespacedKey, String>(); var pdc = mock(org.bukkit.persistence.PersistentDataContainer.class);
|
||||
when(entity.getPersistentDataContainer()).thenReturn(pdc);
|
||||
doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(pdc)
|
||||
.set(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING), anyString());
|
||||
when(pdc.get(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING))).thenAnswer(call -> tags.get(call.getArgument(0)));
|
||||
var chunk = mock(org.bukkit.Chunk.class); when(chunk.getWorld()).thenReturn(player.world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z);
|
||||
when(chunk.getEntities()).thenAnswer(call -> present.get() ? new org.bukkit.entity.Entity[]{entity} : new org.bukkit.entity.Entity[0]);
|
||||
when(player.world.getEntities()).thenAnswer(call -> present.get() ? List.of(entity) : List.of());
|
||||
when(player.world.isChunkLoaded(x, z)).thenReturn(true); when(player.world.getChunkAt(x, z)).thenReturn(chunk);
|
||||
doAnswer(call -> { java.util.function.Consumer<org.bukkit.entity.Villager> configure = call.getArgument(2);
|
||||
configure.accept(entity); present.set(true); return entity; }).when(player.world).spawn(any(org.bukkit.Location.class),
|
||||
eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
|
||||
doAnswer(call -> { present.set(false); return null; }).when(entity).remove();
|
||||
return new Npc(player, entity, anchor, present);
|
||||
}
|
||||
|
||||
private List<JsonObject> rows(String type) {
|
||||
try { return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().endsWith(type)).toList(); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse exported armor evidence", failure); }
|
||||
}
|
||||
|
||||
private static void verifyCatalog(ArmorCatalog catalog, JsonObject row) {
|
||||
var values = row.getAsJsonObject("data");
|
||||
assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
|
||||
assertEquals(catalog.day().toString(), values.get("catalog_day").getAsString());
|
||||
assertEquals(catalog.reason().name().toLowerCase(Locale.ROOT), values.get("refresh_reason").getAsString());
|
||||
assertEquals(catalog.refreshedAt().toString(), row.get("time").getAsString());
|
||||
assertEquals(catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString(),
|
||||
values.get("next_midnight").getAsString());
|
||||
var products = values.getAsJsonArray("products"); assertEquals(18, products.size());
|
||||
var seen = new java.util.HashSet<String>();
|
||||
for (var element : products) {
|
||||
var item = element.getAsJsonObject(); var id = item.get("product_id").getAsString();
|
||||
assertTrue(seen.add(id), "Each product appears once in the complete publication");
|
||||
var parts = id.split("\\."); assertEquals(2, parts.length);
|
||||
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.valueOf(parts[0].toUpperCase(Locale.ROOT)),
|
||||
GearTier.valueOf(parts[1].toUpperCase(Locale.ROOT)));
|
||||
var saved = catalog.offer(product);
|
||||
assertEquals(saved.price().toString(), item.get("price").getAsString());
|
||||
assertEquals(Integer.toString(saved.remaining()), item.get("stock").getAsString());
|
||||
}
|
||||
}
|
||||
private static void ready(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
|
||||
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
private List<JsonObject> catalogs() {
|
||||
try { return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().endsWith("armor.catalog_refreshed")).toList(); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse exported armor catalogue evidence", failure); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorMetadataNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private boolean hasPlacement() {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString().endsWith("armorer.placed")); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse actual Armorer exports", failure); }
|
||||
}
|
||||
|
||||
@Test void unavailableAuditActorNameCannotInterruptAuthorizedDurableRegistration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player);
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
var name = actor.player.getName();
|
||||
doThrow(new IllegalStateException("Unavailable audit actor name")).when(actor.player).getName();
|
||||
try {
|
||||
assertDoesNotThrow(() -> f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "place", "Metadata Forge"}),
|
||||
"Audit actor metadata cannot interrupt an authorized Armorer registration");
|
||||
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().size() == 1);
|
||||
var registration = directory.all().getFirst();
|
||||
assertEquals(java.util.List.of(registration), new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load());
|
||||
assertNotNull(registration.revision());
|
||||
ReskillEventsNativeTest.await(f, () -> hasPlacement());
|
||||
var row = ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(value -> value.get("type").getAsString().endsWith("armorer.placed")).findFirst().orElseThrow();
|
||||
var values = row.getAsJsonObject("data");
|
||||
assertEquals("system", values.get("actor_kind").getAsString(), "Common writer requires complete player identity for its player context");
|
||||
assertEquals("player", values.get("request_actor_kind").getAsString());
|
||||
assertEquals(actor.core.id.toString(), values.get("request_player_id").getAsString());
|
||||
assertFalse(values.has("request_player_name")); assertFalse(values.has("player_name")); assertFalse(row.has("playerid"));
|
||||
assertEquals("unavailable", values.get("actor_capture_quality").getAsString());
|
||||
assertEquals("unloaded", values.get("chunk_status").getAsString());
|
||||
assertEquals("unloaded", values.get("entity_outcome").getAsString());
|
||||
assertFalse(values.has("entity_id"));
|
||||
verify(actor.world, never()).getChunkAt(anyInt(), anyInt());
|
||||
} finally { doReturn(name).when(actor.player).getName(); }
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository;
|
||||
import games.dmg.fantasystick.shop.FantasyArmorer;
|
||||
import games.dmg.fantasystick.shop.FantasyArmorerDirectory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorRegistrationReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void realLegacyStartupDoesNotRewriteOrInventRevisionAndRegisteredRemovalReportsUnavailable() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player);
|
||||
when(actor.world.getEntities()).thenReturn(List.of());
|
||||
var point = actor.core.location;
|
||||
var legacy = new FantasyArmorer(UUID.randomUUID(), "Legacy Forge", actor.world.getUID(), point.getX(), point.getY(), point.getZ(), point.getYaw(), point.getPitch());
|
||||
var repository = new YamlFantasyArmorerRepository(data); repository.save(List.of(legacy));
|
||||
var bytes = Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
f.plugin.setEnabled(true);
|
||||
var directory = (FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
assertEquals(List.of(legacy), directory.all()); assertNull(directory.all().getFirst().revision());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", legacy.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> rows("armorer.removed").size() == 1);
|
||||
var values = rows("armorer.removed").getFirst().getAsJsonObject("data");
|
||||
assertEquals(legacy.id().toString(), values.get("armorer_id").getAsString());
|
||||
assertEquals("unavailable", values.get("revision_capture_quality").getAsString()); assertFalse(values.has("armorer_revision"));
|
||||
assertEquals("unloaded", values.get("chunk_status").getAsString()); assertEquals("none_loaded", values.get("entity_outcome").getAsString());
|
||||
assertFalse(values.has("entity_id")); verify(actor.world, never()).getChunkAt(anyInt(), anyInt());
|
||||
assertTrue(directory.all().isEmpty()); assertTrue(rows("armorer.placed").isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedRealPlacementAndRemovalReplayOnlyOriginalAuditWithoutRecreatingRegistrationOrNpc() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("armor.catalog_refreshed").size() == 1);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player);
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin"); when(actor.world.getEntities()).thenReturn(List.of());
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var directory = (FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
|
||||
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "place", "Replay Forge"});
|
||||
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(message -> message.startsWith("Placed Fantasy Armorer")));
|
||||
var saved = directory.all().getFirst(); assertNotNull(saved.revision());
|
||||
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "remove", saved.id().toString()});
|
||||
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && actor.messages.stream().anyMatch(message -> message.startsWith("Removed Fantasy Armorer")));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
((CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var registrations = Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
var catalog = Files.readAllBytes(data.resolve("armor-catalog.yml")); var feedback = List.copyOf(actor.messages);
|
||||
var intents = new java.util.ArrayList<Properties>();
|
||||
try (var files = Files.list(data.resolve("armor-event-intents"))) {
|
||||
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).toList()) {
|
||||
var properties = new Properties(); try (var input = Files.newInputStream(path)) { properties.load(input); } intents.add(properties);
|
||||
}
|
||||
}
|
||||
assertEquals(2, intents.size()); assertTrue(rows("armorer.placed").isEmpty()); assertTrue(rows("armorer.removed").isEmpty());
|
||||
f.players.clear(); actor.core.online = false; clearInvocations(actor.world); clearInvocations(actor.player);
|
||||
f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("armorer.placed").size() == 1 && rows("armorer.removed").size() == 1);
|
||||
assertTrue(((FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers")).all().isEmpty());
|
||||
assertArrayEquals(registrations, Files.readAllBytes(data.resolve("fantasy-armorers.yml"))); assertArrayEquals(catalog, Files.readAllBytes(data.resolve("armor-catalog.yml")));
|
||||
assertEquals(feedback, actor.messages); verifyNoInteractions(actor.world, actor.player);
|
||||
for (var intent : intents) {
|
||||
var replay = rows("armorer.placed").stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst()
|
||||
.orElseGet(() -> rows("armorer.removed").stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());
|
||||
assertEquals(intent.getProperty("time"), replay.get("time").getAsString());
|
||||
assertEquals(intent.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
|
||||
assertEquals(saved.id().toString(), replay.getAsJsonObject("data").get("armorer_id").getAsString());
|
||||
assertEquals(saved.revision().toString(), replay.getAsJsonObject("data").get("armorer_revision").getAsString());
|
||||
assertEquals(actor.core.id.toString(), replay.get("playerid").getAsString());
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
}
|
||||
private List<JsonObject> rows(String suffix) {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(suffix)).toList(); }
|
||||
catch (Exception failure) { throw new AssertionError("Cannot parse Armorer replay evidence", failure); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.shop.FantasyArmorer;
|
||||
import games.dmg.fantasystick.shop.FantasyArmorerDirectory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
class ArmorerRegistrationRevisionTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void realRegistrationPersistsOneUuidAndRemovalRestoreAndReloadKeepIt() throws Exception {
|
||||
var repository = new YamlFantasyArmorerRepository(data);
|
||||
FantasyArmorer registered;
|
||||
try (var directory = new FantasyArmorerDirectory(repository)) {
|
||||
registered = directory.place("Revision Forge", UUID.randomUUID(), 1.25, 64, -2.5, 90, 10).get();
|
||||
var first = row();
|
||||
assertTrue(first.containsKey("registration-revision"), "New durable registrations must carry a real revision UUID");
|
||||
var revision = UUID.fromString(first.get("registration-revision").toString());
|
||||
assertEquals(registered.id().toString(), first.get("shop-id"));
|
||||
assertEquals(registered, repository.load().getFirst());
|
||||
assertEquals(registered, directory.remove(registered.id()).get().orElseThrow());
|
||||
assertTrue(repository.load().isEmpty());
|
||||
assertEquals(registered, directory.restore(registered).get());
|
||||
assertEquals(revision.toString(), row().get("registration-revision"));
|
||||
}
|
||||
try (var reloaded = new FantasyArmorerDirectory(repository)) {
|
||||
assertEquals(registered, reloaded.find(registered.id()).orElseThrow());
|
||||
assertEquals(registered, repository.load().getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void readingLegacyRegistrationNeverInventsRevisionOrRewritesTheFile() throws Exception {
|
||||
var old = new FantasyArmorer(UUID.randomUUID(), "Legacy Forge", UUID.randomUUID(), 1.25, 64, -2.5, 90, 10);
|
||||
var repository = new YamlFantasyArmorerRepository(data); repository.save(List.of(old));
|
||||
assertFalse(row().containsKey("registration-revision"));
|
||||
var bytes = Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
|
||||
assertEquals(List.of(old), repository.load());
|
||||
try (var directory = new FantasyArmorerDirectory(repository)) { assertEquals(List.of(old), directory.all()); }
|
||||
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
|
||||
repository.save(repository.load()); assertFalse(row().containsKey("registration-revision"));
|
||||
}
|
||||
|
||||
private Map<?, ?> row() throws Exception {
|
||||
var document = new Yaml().load(Files.readString(data.resolve("fantasy-armorers.yml")));
|
||||
assertInstanceOf(Map.class, document);
|
||||
var shops = ((Map<?, ?>) document).get("shops"); assertInstanceOf(List.class, shops);
|
||||
assertInstanceOf(Map.class, ((List<?>) shops).getFirst());
|
||||
return (Map<?, ?>) ((List<?>) shops).getFirst();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user