Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa87b44cc8 | ||
|
|
70ac5f0801 | ||
|
|
d8efc41fb6 |
No files matched your search
@@ -51,6 +51,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.shop.ArmorShopMenus armorShopMenus;
|
||||
private org.bukkit.scheduler.BukkitTask armorMarketPulse;
|
||||
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
|
||||
private games.dmg.fantasystick.persistence.RareSettings rareSettings;
|
||||
private games.dmg.fantasystick.shop.RareInventoryDirectory rareInventory;
|
||||
private games.dmg.fantasystick.shop.RareMerchantDirectory rareMerchants;
|
||||
private games.dmg.fantasystick.shop.RareMerchantController rareMerchantController;
|
||||
private games.dmg.fantasystick.shop.RareMarket rareMarket;
|
||||
private games.dmg.fantasystick.shop.RareShop rareShop;
|
||||
private games.dmg.fantasystick.shop.RareShopMenus rareShopMenus;
|
||||
private games.dmg.fantasystick.shop.RarePotionService rarePotions;
|
||||
private org.bukkit.scheduler.BukkitTask rareMarketPulse,rareMenuPulse;
|
||||
private games.dmg.fantasystick.party.PartyCombat partyCombat;
|
||||
private EquipmentAdminCommand admin;
|
||||
private games.dmg.fantasystick.session.AdminCharacterRecovery characterRecovery;
|
||||
@@ -178,6 +187,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var partyEconomyConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.PartyEconomySettings.keys()) partyEconomyConfiguration.put(key, getConfig().getString(key,games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(key)));
|
||||
var gearConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.GearSettings.keys())gearConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.GearSettings.defaultValue(key)));
|
||||
var rareConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.RareSettings.keys())rareConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.RareSettings.defaultValue(key)));
|
||||
var coinConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.CoinSettings.keys()) {
|
||||
coinConfiguration.put(key, getConfig().getString(key,
|
||||
@@ -252,6 +262,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
partyEconomySettings = new games.dmg.fantasystick.persistence.PartyEconomySettings(
|
||||
getDataFolder().toPath(), partyEconomyConfiguration);
|
||||
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
|
||||
rareSettings=new games.dmg.fantasystick.persistence.RareSettings(getDataFolder().toPath(),rareConfiguration);
|
||||
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
|
||||
hardMobXpSettings = new games.dmg.fantasystick.persistence.HardMobXpSettings(
|
||||
getDataFolder().toPath(), hardMobXp.toString());
|
||||
@@ -266,6 +277,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
new games.dmg.fantasystick.persistence.ArmorPurchaseOperationStore(getDataFolder().toPath()),
|
||||
characters,gearSettings::rules,Clock.systemUTC(),
|
||||
(minimum,maximum)->(int)armorRandom.nextLong(minimum,(long)maximum+1));
|
||||
try{rareInventory=new games.dmg.fantasystick.shop.RareInventoryDirectory(new games.dmg.fantasystick.persistence.YamlRareInventoryRepository(getDataFolder().toPath()));}catch(java.io.IOException invalidRareInventory){throw new java.io.UncheckedIOException("Rare Inventory could not be loaded",invalidRareInventory);}
|
||||
var rareRandom=java.util.random.RandomGenerator.getDefault();rareMarket=new games.dmg.fantasystick.shop.RareMarket(new games.dmg.fantasystick.persistence.YamlRareCatalogRepository(getDataFolder().toPath()),new games.dmg.fantasystick.persistence.RarePurchaseOperationStore(getDataFolder().toPath()),characters,rareInventory,rareSettings::rules,Clock.systemUTC(),(minimum,maximum)->(int)rareRandom.nextLong(minimum,(long)maximum+1));
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
task -> getServer().getScheduler().runTask(this, task),
|
||||
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
|
||||
@@ -331,6 +344,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
() -> adventureQuestSettings.rules().dungeonRoomLinkDistance());
|
||||
if(adventureRepository.requiresDungeonRewardMigration())adventureQuests.migrateLegacyDungeonRewards(id->dungeons.find(id).flatMap(value->value.rooms().stream().filter(room->room.boss()!=null).findFirst().map(room->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.LegacyDungeonReward(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.completionReward().xp(),value.completionReward().coins()),room.id(),dungeons.runView(id,adventureQuests.snapshot().catalog()==null?new java.util.UUID(0,0):adventureQuests.snapshot().catalog().revision()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false))))).join();
|
||||
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
|
||||
rareMerchants=new games.dmg.fantasystick.shop.RareMerchantDirectory(new games.dmg.fantasystick.persistence.YamlRareMerchantRepository(getDataFolder().toPath()));
|
||||
} catch (java.io.IOException invalidManagedState) {
|
||||
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
|
||||
}
|
||||
@@ -560,6 +574,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if(lifetime==generation)localArmorMenus.refreshOpen();});}
|
||||
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
|
||||
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
|
||||
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
|
||||
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
|
||||
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
|
||||
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
|
||||
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
|
||||
rarePotions.install();books.attachRareInventory(rareInventory,rarePotions);
|
||||
adventureQuestController = new games.dmg.fantasystick.adventure.AdventureQuestController(this,
|
||||
adventureQuests, adventureQuestSettings, sessions, parties, characters, rules, nextTick,
|
||||
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault(), particleDelivery);
|
||||
@@ -588,7 +608,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
combatExperience.attachManagedSettlement(dungeonController::settleKill);
|
||||
combatExperience.attachExcluded(adventureQuestController::managed);
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
|| armorerController.tagged(entity) || rareMerchantController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
@@ -620,6 +640,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachGearSettings(gearSettings);
|
||||
admin.attachArmorers(armorers,armorerController);
|
||||
admin.attachArmorMarket(armorMarket);
|
||||
admin.attachRareMarket(rareMerchants,rareMerchantController,rareMarket,rareSettings);
|
||||
admin.attachCoinSettings(coinSettings);
|
||||
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
|
||||
admin.attachAdventureQuests(adventureQuestCommands, adventureQuestSettings);
|
||||
@@ -663,12 +684,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
|
||||
getServer().getPluginManager().registerEvents(armorerController,this);
|
||||
getServer().getPluginManager().registerEvents(armorShopMenus,this);
|
||||
getServer().getPluginManager().registerEvents(rareMerchantController,this);
|
||||
getServer().getPluginManager().registerEvents(rareShopMenus,this);
|
||||
getServer().getPluginManager().registerEvents(adventureQuestController,this);
|
||||
getServer().getPluginManager().registerEvents(fishingController,this);
|
||||
getServer().getPluginManager().registerEvents(dungeonController,this);
|
||||
partyRegistrarController.install();
|
||||
trainingDummyController.install();
|
||||
armorerController.install();
|
||||
rareMerchantController.install();
|
||||
dungeonController.install();
|
||||
adventureQuestController.install();
|
||||
fishingController.install();
|
||||
@@ -677,6 +701,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
localArmorMarket.current().whenComplete((catalog,failure)->{
|
||||
if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Fantasy Armorer restock check failed",failure);});},20L,20L);
|
||||
armorMenuPulse=getServer().getScheduler().runTaskTimer(this,localArmorMenus::refreshOpen,1200L,1200L);
|
||||
var localRareMarket=rareMarket;rareMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;localRareMarket.current().whenComplete((catalog,failure)->{if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Rare Items restock check failed",failure);});},20L,20L);
|
||||
rareMenuPulse=getServer().getScheduler().runTaskTimer(this,localRareMenus::refreshOpen,1200L,1200L);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(healthSteal, this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
@@ -761,6 +787,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingArmorShopMenus=armorShopMenus;armorShopMenus=null;armorShop=null;
|
||||
var closingArmorMarket=armorMarket;armorMarket=null;var closingArmorMarketPulse=armorMarketPulse;armorMarketPulse=null;
|
||||
var closingArmorMenuPulse=armorMenuPulse;armorMenuPulse=null;
|
||||
var closingRareSettings=rareSettings;rareSettings=null;var closingRareInventory=rareInventory;rareInventory=null;
|
||||
var closingRareMerchants=rareMerchants;rareMerchants=null;var closingRareMerchantController=rareMerchantController;rareMerchantController=null;
|
||||
var closingRareMarket=rareMarket;rareMarket=null;var closingRareShopMenus=rareShopMenus;rareShopMenus=null;rareShop=null;rarePotions=null;
|
||||
var closingRareMarketPulse=rareMarketPulse;rareMarketPulse=null;var closingRareMenuPulse=rareMenuPulse;rareMenuPulse=null;
|
||||
var closingCrafting = crafting; crafting = null;
|
||||
var closingGuard = guard; guard = null;
|
||||
var closingAnvil = anvilNames; anvilNames = null;
|
||||
@@ -882,6 +912,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
|
||||
if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
|
||||
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
|
||||
if(closingRareSettings!=null)cleanup("rare configuration",closingRareSettings::close);
|
||||
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
|
||||
if (closingHardMobXpSettings != null) cleanup("hard-mob XP configuration", closingHardMobXpSettings::close);
|
||||
if (closingAdventureQuestSettings != null) cleanup("adventure quest configuration", closingAdventureQuestSettings::close);
|
||||
@@ -911,6 +942,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingArmorers==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorers.drained().toCompletableFuture(),
|
||||
closingArmorMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorMarket.drained().toCompletableFuture(),
|
||||
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
|
||||
closingRareSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareSettings.drained().toCompletableFuture(),
|
||||
closingRareInventory==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareInventory.drained().toCompletableFuture(),
|
||||
closingRareMerchants==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareMerchants.drained().toCompletableFuture(),
|
||||
closingRareMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareMarket.drained().toCompletableFuture(),
|
||||
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyEconomySettings.drained().toCompletableFuture(),
|
||||
closingHardMobXpSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -1017,6 +1052,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if(closingArmorerController!=null)cleanup("armorer entities",closingArmorerController::close);
|
||||
if(closingArmorMarket!=null)cleanup("armor market",closingArmorMarket::close);
|
||||
if(closingArmorers!=null)cleanup("armorers",closingArmorers::close);
|
||||
if(closingRareMarketPulse!=null)cleanup("rare market schedule",closingRareMarketPulse::cancel);
|
||||
if(closingRareMenuPulse!=null)cleanup("rare menu schedule",closingRareMenuPulse::cancel);
|
||||
if(closingRareShopMenus!=null)cleanup("rare shop menus",closingRareShopMenus::close);
|
||||
if(closingRareMerchantController!=null)cleanup("rare merchant entities",closingRareMerchantController::close);
|
||||
if(closingRareMarket!=null)cleanup("rare market",closingRareMarket::close);
|
||||
if(closingRareInventory!=null){if(closingRareMarket==null)cleanup("rare inventory",closingRareInventory::close);else closingRareMarket.drained().whenComplete((ignored,failure)->cleanup("rare inventory",closingRareInventory::close));}
|
||||
if(closingRareMerchants!=null)cleanup("rare merchants",closingRareMerchants::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
if (closingEvents != null) cleanup("session events", closingEvents::close);
|
||||
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
|
||||
@@ -1030,6 +1072,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingTrainingDummyController);
|
||||
unregister(closingPartyRegistrarMenus);
|
||||
unregister(closingArmorerController);unregister(closingArmorShopMenus);
|
||||
unregister(closingRareMerchantController);unregister(closingRareShopMenus);
|
||||
unregister(closingAdventureQuestController);
|
||||
unregister(closingFishingController);
|
||||
unregister(closingDungeonController);
|
||||
|
||||
@@ -30,6 +30,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
|
||||
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private games.dmg.fantasystick.adventure.FishingDirectory fishing;
|
||||
private games.dmg.fantasystick.shop.RareInventoryDirectory rareInventory;
|
||||
private games.dmg.fantasystick.shop.RarePotionService rarePotions;
|
||||
private final org.bukkit.Server server;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
|
||||
@@ -45,9 +47,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlock");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlearn");
|
||||
private static final net.kyori.adventure.key.Key RARE_USE = net.kyori.adventure.key.Key.key("fantasystick", "rare_use");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
|
||||
private static final java.util.regex.Pattern RARE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9-]{0,63})\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
@@ -56,10 +60,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private record RareView(String nonce,ControlInput input,java.util.Map<String,java.math.BigInteger> quantities){}
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, PassiveView> passiveViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID,RareView> rareViews=new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
@@ -103,6 +109,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
public void attachFishingInventory(games.dmg.fantasystick.adventure.FishingDirectory fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing inventory already attached or books closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);}
|
||||
|
||||
@Override public void accept(ControlInput input) { load(input, 0); }
|
||||
|
||||
@@ -229,6 +236,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
rareViews.remove(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
@@ -387,7 +395,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
pages.add(navigation);
|
||||
}
|
||||
if(end==allOverview.size())pages.addAll(inventoryPages(record.id()));
|
||||
if(end==allOverview.size())pages.addAll(inventoryPages(record.id(),input));
|
||||
if (end == allOverview.size()) pages.addAll(interactive
|
||||
? partyControls.pages(input) : savedState
|
||||
? partyControls.readOnlyCharacterPages(record.id())
|
||||
@@ -397,7 +405,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return item;
|
||||
}
|
||||
|
||||
private java.util.List<Component> inventoryPages(UUID character){var directory=fishing;if(directory==null)return java.util.List.of();var values=directory.inventory(character);var trophies=directory.trophies(character);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty())builder.text("No quest items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}if(values.size()+trophies.size()>256)builder.text("More items are retained.");}return builder.pages();}
|
||||
private java.util.List<Component> inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;builder.inline(good.name()+": "+good.quantity(),view!=null&&good.available()?rareButton(view,good.id()):null);if(!good.available())builder.text("Unavailable item type");}if(values.size()+trophies.size()+goods.size()>256)builder.text("More items are retained.");}return builder.pages();}
|
||||
private static Component rareButton(RareView view,String product){return Component.text("[Use]",net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_USE,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+":"+product+"\"")));}
|
||||
|
||||
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
|
||||
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
|
||||
@@ -895,6 +904,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
};
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void rareAction(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rarePotions==null||!RARE_USE.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=rareViews.get(player.getUniqueId());if(view==null||view.input().player()!=player)return;String raw=event.getTag().string();if(raw.length()>120)return;var match=RARE_PAYLOAD.matcher(raw);var product=match.matches()?games.dmg.fantasystick.shop.RareProduct.find(match.group(2)).orElse(null):null;if(product==null||!view.nonce().equals(match.group(1))||!view.quantities().getOrDefault(product.id(),java.math.BigInteger.ZERO).equals(rarePotions.quantity(view.input().session().character(),product))||!rareViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;rarePotions.use(player,product,outcome->{if(closed||!view.input().stillCurrent())return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used "+product.displayName()+" and restored "+outcome.healed().toPlainString()+" health.";case FULL_HEALTH->"Potion use refused: health is already full.";case NO_EFFECT->"Potion use refused: no positive healing was accepted.";case NOT_AVAILABLE->"Potion use refused: that item is no longer available.";case STALE->"Potion use refused: the character, session or quantity changed.";case FAILED->"Potion use could not be acknowledged; retained proof prevents replay.";});load(view.input(),0);});}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
|
||||
|
||||
@@ -909,10 +921,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
rareViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
|
||||
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -467,6 +467,11 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
/** One character-file transaction owns both the exact coin debit and strict gear replacement. */
|
||||
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target),old.sheetGeneration());repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum RarePurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,STALE,UNAVAILABLE }
|
||||
public record RarePurchase(RarePurchaseStatus status,Optional<CharacterRecord> record){}
|
||||
/** One character-file transaction owns a rare purchase's exact idempotent coin debit. */
|
||||
public CompletableFuture<RarePurchase> purchaseRare(UUID characterId,BigInteger expectedRevision,UUID operationId,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(price);if(price.signum()<=0)throw new IllegalArgumentException("Invalid rare purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new RarePurchase(RarePurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new RarePurchase(RarePurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new RarePurchase(RarePurchaseStatus.STALE,Optional.of(old));if(old.coins().compareTo(price)<0)return new RarePurchase(RarePurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new RarePurchase(RarePurchaseStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, DUPLICATE, STALE, UNAVAILABLE }
|
||||
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareCatalog;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Durable adapter boundary for the separate shared rare catalog. */
|
||||
public interface RareCatalogRepository {
|
||||
Optional<RareCatalog> load() throws IOException;
|
||||
void save(RareCatalog catalog) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareProduct;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable recovery plans spanning one catalog reservation and character gear purchase. */
|
||||
public final class RarePurchaseOperationStore implements AutoCloseable {
|
||||
public record Plan(UUID id, UUID character, BigInteger expectedCharacterRevision, UUID catalogRevision,
|
||||
RareProduct product, BigInteger price) {
|
||||
public Plan {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(character); Objects.requireNonNull(expectedCharacterRevision);
|
||||
Objects.requireNonNull(catalogRevision); Objects.requireNonNull(product); Objects.requireNonNull(price);
|
||||
if(expectedCharacterRevision.signum()<0||price.signum()<=0)throw new IllegalArgumentException("Invalid Rare purchase plan");
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-rare-operation-persistence"));
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
|
||||
public RarePurchaseOperationStore(Path data){directory=data.toAbsolutePath().resolve("rare-purchase-operations");}
|
||||
|
||||
public CompletionStage<Plan> prepare(Plan plan){return submit(()->{
|
||||
Files.createDirectories(directory);Path target=file(plan.id());
|
||||
if(Files.exists(target)) {
|
||||
var existing=decode(Files.readString(target,StandardCharsets.UTF_8));
|
||||
if(!existing.equals(plan))throw new IOException("rare operation identity collision");
|
||||
return existing;
|
||||
}
|
||||
write(target,encode(plan));return plan;
|
||||
});}
|
||||
public CompletionStage<Void> complete(UUID id){return submit(()->{if(Files.deleteIfExists(file(id)))DurableFiles.forceDirectory(directory);return null;});}
|
||||
public CompletionStage<List<Plan>> load(){return submit(this::loadBlocking);}
|
||||
|
||||
private List<Plan> loadBlocking()throws IOException {
|
||||
if(Files.notExists(directory))return List.of();
|
||||
var result=new ArrayList<Plan>();
|
||||
try(var files=Files.list(directory)) {
|
||||
for(var path:files.filter(value->value.getFileName().toString().endsWith(".yml")).sorted().toList()) {
|
||||
if(Files.size(path)>65_536)throw new IOException("rare operation too large");
|
||||
result.add(decode(Files.readString(path,StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
private Path file(UUID id){return directory.resolve(id+".yml");}
|
||||
private static String encode(Plan plan){Map<String,Object> row=new LinkedHashMap<>();row.put("schema-version",1);
|
||||
row.put("operation-id",plan.id().toString());row.put("character",plan.character().toString());
|
||||
row.put("expected-character-revision",plan.expectedCharacterRevision().toString());
|
||||
row.put("catalog-revision",plan.catalogRevision().toString());row.put("product",plan.product().id());
|
||||
row.put("price",plan.price().toString());return yaml().dump(row);}
|
||||
private static Plan decode(String input){Object decoded=yaml().load(input);if(!(decoded instanceof Map<?,?> row)||
|
||||
!"1".equals(String.valueOf(row.get("schema-version"))))throw new IllegalArgumentException("Invalid rare operation schema");
|
||||
return new Plan(UUID.fromString(text(row,"operation-id")),UUID.fromString(text(row,"character")),
|
||||
new BigInteger(text(row,"expected-character-revision")),UUID.fromString(text(row,"catalog-revision")),
|
||||
RareProduct.find(text(row,"product")).orElseThrow(),new BigInteger(text(row,"price")));}
|
||||
private static void write(Path target,String content)throws IOException {Path temporary=Files.createTempFile(target.getParent(),"rare-operation-",".tmp");
|
||||
try{byte[] bytes=content.getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
|
||||
var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}
|
||||
try{Files.move(temporary,target,StandardCopyOption.ATOMIC_MOVE);}catch(AtomicMoveNotSupportedException unsupported){
|
||||
throw new IOException("Atomic rare-operation persistence unavailable",unsupported);}DurableFiles.forceDirectory(target.getParent());}
|
||||
finally{Files.deleteIfExists(temporary);}}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);
|
||||
options.setCodePointLimit(65_536);return new Yaml(new SafeConstructor(options));}
|
||||
private static String text(Map<?,?> row,String key){Object value=row.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
|
||||
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(
|
||||
new IOException("rare operation store closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}
|
||||
catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
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,34 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.*;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Save-before-publication configuration for rare catalogs and future potion uses. */
|
||||
public final class RareSettings implements AutoCloseable {
|
||||
public static final String VARIANCE="rare-price-variance-percent";
|
||||
public record Update(String key,String value,Map<String,String> values,long sequence){public Update{values=Map.copyOf(values);}}
|
||||
private static final Map<String,String> DEFAULTS=defaults();private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
|
||||
private final Path directory;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-configuration"));private volatile Map<String,String> effective;private Map<String,String> persisted;private long savedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public RareSettings(Path directory,Map<String,String> initial){this.directory=directory.toAbsolutePath();var values=new LinkedHashMap<String,String>();for(var key:KEYS)values.put(key,Objects.requireNonNull(initial.getOrDefault(key,DEFAULTS.get(key))));validate(values);effective=Map.copyOf(values);persisted=effective;}
|
||||
public static List<String> keys(){return KEYS;}public static String defaultValue(String key){return DEFAULTS.get(key);}public String value(String key){return effective.get(key);}
|
||||
public RareMarketRules rules(){var values=effective;var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);var stock=new EnumMap<RareProduct,RareMarketRules.StockRange>(RareProduct.class);var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);for(var product:RareProduct.values()){prices.put(product,new BigInteger(values.get(priceKey(product))));stock.put(product,new RareMarketRules.StockRange(whole(values.get(stockKey(product,true)),0,Integer.MAX_VALUE),whole(values.get(stockKey(product,false)),0,Integer.MAX_VALUE)));healing.put(product,whole(values.get(healingKey(product)),0,100));}return new RareMarketRules(prices,whole(values.get(VARIANCE),0,100),stock,healing);}
|
||||
public CompletionStage<Update> set(String key,String raw){if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown rare setting");String accepted=Integer.toString(whole(raw,0,key.equals(VARIANCE)||key.endsWith("-healing-percent")?100:Integer.MAX_VALUE));var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);validate(preview);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);validate(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;validate(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
|
||||
public static String priceKey(RareProduct product){return "rare-price-"+product.id();}public static String stockKey(RareProduct product,boolean minimum){return "rare-stock-"+product.id()+(minimum?"-min":"-max");}public static String healingKey(RareProduct product){return "rare-"+product.id()+"-healing-percent";}
|
||||
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();values.put(VARIANCE,"10");for(var product:RareProduct.values()){values.put(priceKey(product),Integer.toString(product.basePrice()));values.put(stockKey(product,true),"0");values.put(stockKey(product,false),Integer.toString(product.maximumStock()));values.put(healingKey(product),Integer.toString(product.defaultHealingPercent()));}return Map.copyOf(values);}
|
||||
private static void validate(Map<String,String> values){whole(values.get(VARIANCE),0,100);for(var product:RareProduct.values()){whole(values.get(priceKey(product)),0,Integer.MAX_VALUE);int min=whole(values.get(stockKey(product,true)),0,Integer.MAX_VALUE),max=whole(values.get(stockKey(product,false)),0,Integer.MAX_VALUE);if(min>max)throw new IllegalArgumentException("Stock minimum exceeds maximum");whole(values.get(healingKey(product)),0,100);}}
|
||||
private static int whole(String value,int minimum,int maximum){var number=new BigInteger(Objects.requireNonNull(value));if(number.compareTo(BigInteger.valueOf(minimum))<0||number.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return number.intValueExact();}
|
||||
private void save(String key,String value)throws IOException{Files.createDirectories(directory);var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(1_048_576);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> document=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(1_048_577);}if(bytes.length>1_048_576)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");document.put(savedKey,entry.getValue());}}document.put(key,Integer.parseInt(value));byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);Path temporary=Files.createTempFile(directory,".config-rare-",".tmp");try{Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);DurableFiles.forceDirectory(directory);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}}
|
||||
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,90 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareCatalog;
|
||||
import games.dmg.fantasystick.shop.RareProduct;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Forced atomic persistence for the one shared rare catalog. */
|
||||
public final class YamlRareCatalogRepository implements RareCatalogRepository {
|
||||
private final Path file;
|
||||
public YamlRareCatalogRepository(Path data){file=data.toAbsolutePath().resolve("rare-catalog.yml");}
|
||||
|
||||
@Override public Optional<RareCatalog> load() throws IOException {
|
||||
if(Files.notExists(file))return Optional.empty();
|
||||
try {
|
||||
if(Files.size(file)>1_048_576)throw new IllegalArgumentException("Catalog too large");
|
||||
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));
|
||||
if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(String.valueOf(root.get("schema-version")))!=1)
|
||||
throw new IllegalArgumentException("Invalid schema");
|
||||
var products=new LinkedHashMap<RareProduct,RareCatalog.Offer>();
|
||||
for(Object value:list(root.get("products"))) {
|
||||
var row=map(value);
|
||||
var product=parseProduct(text(row,"product"));
|
||||
if(products.put(product,new RareCatalog.Offer(new BigInteger(text(row,"price")),
|
||||
Integer.parseInt(text(row,"remaining"))))!=null)throw new IllegalArgumentException("Duplicate product");
|
||||
}
|
||||
var transactions=new LinkedHashMap<UUID,RareCatalog.Transaction>();
|
||||
Object savedTransactions=root.get("transactions");
|
||||
if(savedTransactions!=null)for(Object value:list(savedTransactions)) {
|
||||
var row=map(value);var id=UUID.fromString(text(row,"operation-id"));
|
||||
var transaction=new RareCatalog.Transaction(parseProduct(text(row,"product")),
|
||||
RareCatalog.TransactionState.valueOf(text(row,"state")));
|
||||
if(transactions.put(id,transaction)!=null)throw new IllegalArgumentException("Duplicate operation");
|
||||
}
|
||||
return Optional.of(new RareCatalog(LocalDate.parse(text(root,"catalog-day")),
|
||||
UUID.fromString(text(root,"revision")),Instant.parse(text(root,"refreshed-at")),
|
||||
RareCatalog.RefreshReason.valueOf(text(root,"refresh-reason")),products,transactions));
|
||||
} catch(RuntimeException invalid) { throw new IOException("Invalid rare catalog",invalid); }
|
||||
}
|
||||
|
||||
@Override public void save(RareCatalog catalog) throws IOException {
|
||||
Objects.requireNonNull(catalog);
|
||||
Map<String,Object> root=new LinkedHashMap<>();
|
||||
root.put("schema-version",1);
|
||||
root.put("catalog-day",catalog.day().toString());
|
||||
root.put("revision",catalog.revision().toString());
|
||||
root.put("refreshed-at",catalog.refreshedAt().toString());
|
||||
root.put("refresh-reason",catalog.reason().name());
|
||||
root.put("products",catalog.products().entrySet().stream().sorted(Map.Entry.comparingByKey(
|
||||
Comparator.comparing(RareProduct::id)))
|
||||
.map(entry->{Map<String,Object> row=new LinkedHashMap<>();row.put("product",product(entry.getKey()));
|
||||
row.put("price",entry.getValue().price().toString());row.put("remaining",Integer.toString(entry.getValue().remaining()));return row;}).toList());
|
||||
root.put("transactions",catalog.transactions().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{
|
||||
Map<String,Object> row=new LinkedHashMap<>();row.put("operation-id",entry.getKey().toString());
|
||||
row.put("product",product(entry.getValue().product()));row.put("state",entry.getValue().state().name());return row;}).toList());
|
||||
write(yaml().dump(root));
|
||||
}
|
||||
|
||||
private void write(String content)throws IOException {
|
||||
Files.createDirectories(file.getParent());
|
||||
Path temporary=Files.createTempFile(file.getParent(),"rare-catalog-",".tmp");
|
||||
try {
|
||||
byte[] bytes=content.getBytes(StandardCharsets.UTF_8);
|
||||
try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
|
||||
var buffer=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 rare catalog persistence unavailable",unsupported);}
|
||||
DurableFiles.forceDirectory(file.getParent());
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private static String product(RareProduct product){return product.id();}
|
||||
private static RareProduct parseProduct(String value){return RareProduct.find(value).orElseThrow();}
|
||||
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("Invalid "+key);}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareInventoryState;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Forced-atomic adapter for persistent typed rare goods and use proof. */
|
||||
public final class YamlRareInventoryRepository {
|
||||
private final Path file;
|
||||
public YamlRareInventoryRepository(Path data){file=data.toAbsolutePath().resolve("rare-inventory.yml");}
|
||||
public RareInventoryState load()throws IOException{if(Files.notExists(file))return new RareInventoryState();try{if(Files.size(file)>1_048_576)throw new IllegalArgumentException();Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(String.valueOf(root.get("schema-version")))!=1)throw new IllegalArgumentException();var items=new LinkedHashMap<UUID,Map<String,BigInteger>>();for(Object value:list(root.get("characters"))){var row=map(value);var goods=new LinkedHashMap<String,BigInteger>();for(Object itemValue:list(row.get("items"))){var item=map(itemValue);goods.put(text(item,"product"),new BigInteger(text(item,"quantity")));}items.put(UUID.fromString(text(row,"character")),goods);}var receipts=new LinkedHashMap<UUID,RareInventoryState.PurchaseReceipt>();for(Object value:list(root.get("purchase-receipts"))){var row=map(value);receipts.put(UUID.fromString(text(row,"operation")),new RareInventoryState.PurchaseReceipt(UUID.fromString(text(row,"character")),text(row,"product")));}var uses=new LinkedHashMap<UUID,RareInventoryState.Use>();for(Object value:list(root.get("uses"))){var row=map(value);UUID operation=UUID.fromString(text(row,"operation"));uses.put(operation,new RareInventoryState.Use(operation,UUID.fromString(text(row,"character")),text(row,"product"),new BigInteger(text(row,"expected-quantity")),new BigDecimal(text(row,"health-before")),new BigDecimal(text(row,"maximum")),Integer.parseInt(text(row,"percent")),RareInventoryState.UsePhase.valueOf(text(row,"phase"))));}return new RareInventoryState(items,receipts,uses);}catch(RuntimeException invalid){throw new IOException("Invalid rare inventory",invalid);}}
|
||||
public void save(RareInventoryState state)throws IOException{Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("characters",state.items().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{Map<String,Object> row=new LinkedHashMap<>();row.put("character",entry.getKey().toString());row.put("items",entry.getValue().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(item->{Map<String,Object> value=new LinkedHashMap<>();value.put("product",item.getKey());value.put("quantity",item.getValue().toString());return value;}).toList());return row;}).toList());root.put("purchase-receipts",state.purchaseReceipts().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{Map<String,Object> row=new LinkedHashMap<>();row.put("operation",entry.getKey().toString());row.put("character",entry.getValue().character().toString());row.put("product",entry.getValue().product());return row;}).toList());root.put("uses",state.uses().values().stream().sorted(Comparator.comparing(RareInventoryState.Use::operation)).map(use->{Map<String,Object> row=new LinkedHashMap<>();row.put("operation",use.operation().toString());row.put("character",use.character().toString());row.put("product",use.product());row.put("expected-quantity",use.expectedQuantity().toString());row.put("health-before",use.healthBefore().toPlainString());row.put("maximum",use.maximum().toPlainString());row.put("percent",Integer.toString(use.percent()));row.put("phase",use.phase().name());return row;}).toList());write(yaml().dump(root));}
|
||||
private void write(String content)throws IOException{Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"rare-inventory-",".tmp");try{byte[] bytes=content.getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=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 unavailable){throw new IOException("Atomic rare inventory persistence unavailable",unavailable);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
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==null)return List.of();if(value instanceof List<?> values)return values;throw new IllegalArgumentException();}
|
||||
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> values)return values;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("Invalid "+key);}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import games.dmg.fantasystick.shop.RareMerchant;import java.io.*;import java.nio.charset.StandardCharsets;import java.nio.file.*;import java.util.*;import org.yaml.snakeyaml.*;import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
/** Forced atomic desired-state adapter for Fantasy Rare merchant registrations. */
|
||||
public final class YamlRareMerchantRepository{
|
||||
private final Path file;public YamlRareMerchantRepository(Path data){file=data.toAbsolutePath().resolve("rare-merchants.yml");}
|
||||
public List<RareMerchant> 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<RareMerchant>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new RareMerchant(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 Rare merchant file",invalid);}}
|
||||
public void save(List<RareMerchant> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(RareMerchant::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(),"rare-merchants-",".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 Rare merchant persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static void validate(List<RareMerchant> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Rare merchant 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();}
|
||||
}
|
||||
@@ -96,6 +96,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
|
||||
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
|
||||
private games.dmg.fantasystick.shop.RareMerchantDirectory rareMerchants;
|
||||
private games.dmg.fantasystick.shop.RareMerchantController rareMerchantController;
|
||||
private games.dmg.fantasystick.shop.RareMarket rareMarket;
|
||||
private games.dmg.fantasystick.persistence.RareSettings rareSettings;
|
||||
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
|
||||
private games.dmg.fantasystick.persistence.CharacterStorage characters;
|
||||
private AdminCharacterRecovery characterRecovery;
|
||||
@@ -418,6 +422,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
|
||||
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
|
||||
public void attachRareMarket(games.dmg.fantasystick.shop.RareMerchantDirectory directory,games.dmg.fantasystick.shop.RareMerchantController controller,games.dmg.fantasystick.shop.RareMarket market,games.dmg.fantasystick.persistence.RareSettings settings){if(closed||rareMerchants!=null)throw new IllegalStateException("Rare market already attached or command closed");rareMerchants=Objects.requireNonNull(directory);rareMerchantController=Objects.requireNonNull(controller);rareMarket=Objects.requireNonNull(market);rareSettings=Objects.requireNonNull(settings);}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
@@ -445,6 +450,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
if(dungeonCommands!=null&&args.length>0&&args[0].equals("dungeon")){dungeonCommands.execute(sender,args);return true;}
|
||||
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
|
||||
if(args.length>0&&args[0].equals("rare-shop")){rareShop(sender,args);return true;}
|
||||
if (args.length > 0 && args[0].equals("training-dummy")) {
|
||||
trainingDummy(sender, args); return true;
|
||||
}
|
||||
@@ -545,7 +551,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private static void adminUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fstickadmin <adventure reroll [confirm]|adventure-site|character view|config|dungeon|experience|give|inspect|party-registrar|quest-giver|recover|revive|shop|training-dummy> ...");
|
||||
sender.sendMessage("Usage: /fstickadmin <adventure reroll [confirm]|adventure-site|character view|config|dungeon|experience|give|inspect|party-registrar|quest-giver|rare-shop|recover|revive|shop|training-dummy> ...");
|
||||
}
|
||||
|
||||
private void characterCommand(CommandSender sender, String[] args) {
|
||||
@@ -759,6 +765,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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 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));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);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>");}
|
||||
|
||||
private void trainingDummy(CommandSender sender, String[] args) {
|
||||
if (trainingDummies == null || trainingDummyController == null || args.length < 2) {
|
||||
trainingDummyUsage(sender); return;
|
||||
@@ -911,6 +920,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
|
||||
if (partyEconomySettings != null && games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])) { partyEconomyConfiguration(sender,args); return; }
|
||||
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){gearConfiguration(sender,args);return;}
|
||||
if(rareSettings!=null&&games.dmg.fantasystick.persistence.RareSettings.keys().contains(args[1])){rareConfiguration(sender,args);return;}
|
||||
if (coinSettings != null && games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])) { coinConfiguration(sender,args); return; }
|
||||
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
|
||||
hardMobXpConfiguration(sender, args); return;
|
||||
@@ -1543,6 +1553,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+partyEconomySettings.value(key)+" (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(!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 rareConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+rareSettings.value(key)+(key.endsWith("-healing-percent")?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and 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&&rareSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
|
||||
|
||||
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers 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&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
@@ -1635,8 +1646,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
|
||||
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), args[0]);
|
||||
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "revive", "shop", "training-dummy")
|
||||
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"), args[0]);
|
||||
if (args.length == 2 && args[0].equals("character")) {
|
||||
var routes = new java.util.ArrayList<>(List.of("find"));
|
||||
if (sender instanceof org.bukkit.entity.Player) routes.addAll(List.of("recover", "show", "view"));
|
||||
@@ -1661,6 +1672,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
.map(org.bukkit.entity.Player::getName).toList(), args[2]);
|
||||
if(args.length==2&&args[0].equals("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
|
||||
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
|
||||
if(args.length==2&&args[0].equals("rare-shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
|
||||
if(args.length==3&&args[0].equals("rare-shop")&&args[1].equals("remove")&&rareMerchants!=null)return matching(rareMerchants.all().stream().map(value->value.id().toString()).toList(),args[2]);
|
||||
if (args.length == 2 && args[0].equals("training-dummy")) return matching(
|
||||
sender instanceof org.bukkit.entity.Player ? List.of("list", "place", "remove") : List.of("list", "remove"), args[1]);
|
||||
if (args.length == 3 && args[0].equals("training-dummy") && args[1].equals("remove") && trainingDummies != null)
|
||||
@@ -1680,6 +1693,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.PartyEconomySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.GearSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.RareSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.CoinSettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.AdventureQuestSettings.keys());
|
||||
@@ -1715,6 +1729,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
|
||||
if(partyEconomySettings!=null&&games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])){String current=partyEconomySettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){String current=gearSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.GearSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(rareSettings!=null&&games.dmg.fantasystick.persistence.RareSettings.keys().contains(args[1])){String current=rareSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.RareSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(coinSettings!=null&&games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])){String current=coinSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.CoinSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
|
||||
String current = hardMobXpSettings.value(); String defaults = games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** One durable server-wide rare catalog shared by every registered merchant. */
|
||||
public record RareCatalog(LocalDate day,UUID revision,Instant refreshedAt,RefreshReason reason,
|
||||
Map<RareProduct,Offer> products,Map<UUID,Transaction> transactions){
|
||||
public enum RefreshReason{STARTUP,SCHEDULED,ADMINISTRATIVE}
|
||||
public enum TransactionState{RESERVED,CONSUMED,CANCELLED}
|
||||
public record Offer(BigInteger price,int remaining){public Offer{Objects.requireNonNull(price);if(price.signum()<=0||remaining<0)throw new IllegalArgumentException("Invalid offer");}}
|
||||
public record Transaction(RareProduct product,TransactionState state){public Transaction{Objects.requireNonNull(product);Objects.requireNonNull(state);}}
|
||||
public RareCatalog(LocalDate day,UUID revision,Instant refreshedAt,RefreshReason reason,Map<RareProduct,Offer> products){this(day,revision,refreshedAt,reason,products,Map.of());}
|
||||
public RareCatalog{Objects.requireNonNull(day);Objects.requireNonNull(revision);Objects.requireNonNull(refreshedAt);Objects.requireNonNull(reason);var copy=new EnumMap<RareProduct,Offer>(RareProduct.class);copy.putAll(products);if(copy.size()!=RareProduct.values().length)throw new IllegalArgumentException("Catalog must contain every delivered product");products=Map.copyOf(copy);transactions=Map.copyOf(transactions);}
|
||||
public Offer offer(RareProduct product){var offer=products.get(Objects.requireNonNull(product));if(offer==null)throw new IllegalArgumentException("Product unavailable");return offer;}
|
||||
public RareCatalog reserve(UUID operation,RareProduct product){var existing=transactions.get(operation);if(existing!=null){if(existing.product()!=product)throw new IllegalArgumentException("Operation collision");return this;}var offer=offer(product);if(offer.remaining()==0)throw new IllegalStateException("Sold out");var nextProducts=new LinkedHashMap<>(products);nextProducts.put(product,new Offer(offer.price(),offer.remaining()-1));var nextTransactions=new LinkedHashMap<>(transactions);nextTransactions.put(operation,new Transaction(product,TransactionState.RESERVED));return new RareCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);}
|
||||
public RareCatalog settle(UUID operation,boolean consumed){var transaction=transactions.get(operation);if(transaction==null||transaction.state()!=TransactionState.RESERVED)return this;var nextProducts=new LinkedHashMap<>(products);if(!consumed){var offer=offer(transaction.product());nextProducts.put(transaction.product(),new Offer(offer.price(),Math.addExact(offer.remaining(),1)));}var nextTransactions=new LinkedHashMap<>(transactions);nextTransactions.put(operation,new Transaction(transaction.product(),consumed?TransactionState.CONSUMED:TransactionState.CANCELLED));return new RareCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);}
|
||||
public RareCatalog prune(UUID operation){if(!transactions.containsKey(operation))return this;var next=new LinkedHashMap<>(transactions);next.remove(operation);return new RareCatalog(day,revision,refreshedAt,reason,products,next);}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Exact inclusive stock and basis-point price generation without floating-point drift. */
|
||||
public final class RareCatalogGenerator {
|
||||
@FunctionalInterface public interface InclusiveRoll{int roll(int minimum,int maximum);}
|
||||
private static final BigInteger BASIS=BigInteger.valueOf(10_000);
|
||||
private RareCatalogGenerator(){}
|
||||
public static RareCatalog generate(LocalDate day,Instant refreshedAt,RareCatalog.RefreshReason reason,RareMarketRules rules,InclusiveRoll random){Objects.requireNonNull(rules);Objects.requireNonNull(random);var offers=new EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);for(var product:RareProduct.values()){int spread=Math.multiplyExact(100,rules.variancePercent());int offset=checked(random,-spread,spread);var numerator=rules.basePrices().get(product).multiply(BigInteger.valueOf(10_000L+offset));var divided=numerator.divideAndRemainder(BASIS);var price=divided[0].add(divided[1].signum()==0?BigInteger.ZERO:BigInteger.ONE).max(BigInteger.ONE);var range=rules.stock().get(product);offers.put(product,new RareCatalog.Offer(price,checked(random,range.minimum(),range.maximum())));}return new RareCatalog(day,UUID.randomUUID(),refreshedAt,reason,offers);}
|
||||
private static int checked(InclusiveRoll random,int minimum,int maximum){int value=random.roll(minimum,maximum);if(value<minimum||value>maximum)throw new IllegalArgumentException("Random roll outside range");return value;}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.YamlRareInventoryRepository;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Serialized durable authority for character-owned rare goods and one-use reservations. */
|
||||
public final class RareInventoryDirectory implements AutoCloseable {
|
||||
public enum AddStatus{APPLIED,DUPLICATE}
|
||||
public enum UseStatus{RESERVED,COMMITTED,REFUNDED,NOT_AVAILABLE,STALE}
|
||||
public record UseOutcome(UseStatus status,RareInventoryState.Use use){}
|
||||
public record Item(String id,String name,BigInteger quantity,boolean available){}
|
||||
private final YamlRareInventoryRepository repository;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-inventory"));
|
||||
private volatile RareInventoryState snapshot;
|
||||
private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public RareInventoryDirectory(YamlRareInventoryRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}
|
||||
public BigInteger quantity(UUID character,RareProduct product){return snapshot.quantity(character,product.id());}
|
||||
public java.util.List<Item> inventory(UUID character){return snapshot.items().getOrDefault(character,Map.of()).entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{var product=RareProduct.find(entry.getKey());return new Item(entry.getKey(),product.map(RareProduct::displayName).orElse(entry.getKey()),entry.getValue(),product.isPresent());}).toList();}
|
||||
public CompletableFuture<AddStatus> add(UUID character,RareProduct product,UUID operation){Objects.requireNonNull(character);Objects.requireNonNull(product);Objects.requireNonNull(operation);return submit(()->{var receipt=snapshot.purchaseReceipts().get(operation);if(receipt!=null){if(!receipt.character().equals(character)||!receipt.product().equals(product.id()))throw new IOException("Rare purchase operation collision");return AddStatus.DUPLICATE;}var items=mutableItems(snapshot.items());var goods=items.computeIfAbsent(character,ignored->new LinkedHashMap<>());goods.merge(product.id(),BigInteger.ONE,BigInteger::add);var receipts=new LinkedHashMap<>(snapshot.purchaseReceipts());receipts.put(operation,new RareInventoryState.PurchaseReceipt(character,product.id()));save(new RareInventoryState(items,receipts,snapshot.uses()));return AddStatus.APPLIED;});}
|
||||
public CompletableFuture<UseOutcome> reserveUse(UUID character,RareProduct product,UUID operation,BigInteger expectedQuantity,BigDecimal healthBefore,BigDecimal maximum,int percent){Objects.requireNonNull(character);Objects.requireNonNull(product);Objects.requireNonNull(operation);return submit(()->{var existing=snapshot.uses().get(operation);if(existing!=null){if(!existing.character().equals(character)||!existing.product().equals(product.id()))throw new IOException("Rare use operation collision");return new UseOutcome(status(existing.phase()),existing);}var current=snapshot.quantity(character,product.id());if(current.signum()<=0)return new UseOutcome(UseStatus.NOT_AVAILABLE,null);if(!current.equals(expectedQuantity))return new UseOutcome(UseStatus.STALE,null);RareInventoryState.Use use;try{use=new RareInventoryState.Use(operation,character,product.id(),expectedQuantity,healthBefore,maximum,percent,RareInventoryState.UsePhase.RESERVED);}catch(IllegalArgumentException refused){return new UseOutcome(UseStatus.NOT_AVAILABLE,null);}var items=mutableItems(snapshot.items());var goods=items.get(character);var remaining=current.subtract(BigInteger.ONE);if(remaining.signum()==0)goods.remove(product.id());else goods.put(product.id(),remaining);if(goods.isEmpty())items.remove(character);var uses=new LinkedHashMap<>(snapshot.uses());uses.put(operation,use);save(new RareInventoryState(items,snapshot.purchaseReceipts(),uses));return new UseOutcome(UseStatus.RESERVED,use);});}
|
||||
public CompletableFuture<UseStatus> commitUse(UUID operation){return updateUse(operation,true);}
|
||||
public CompletableFuture<UseStatus> refundUse(UUID operation){return updateUse(operation,false);}
|
||||
private CompletableFuture<UseStatus> updateUse(UUID operation,boolean commit){return submit(()->{var use=snapshot.uses().get(operation);if(use==null)return UseStatus.NOT_AVAILABLE;if(use.phase()!=RareInventoryState.UsePhase.RESERVED)return status(use.phase());var phase=commit?RareInventoryState.UsePhase.COMMITTED:RareInventoryState.UsePhase.REFUNDED;var changed=new RareInventoryState.Use(use.operation(),use.character(),use.product(),use.expectedQuantity(),use.healthBefore(),use.maximum(),use.percent(),phase);var uses=new LinkedHashMap<>(snapshot.uses());uses.put(operation,changed);var items=mutableItems(snapshot.items());if(!commit)items.computeIfAbsent(use.character(),ignored->new LinkedHashMap<>()).merge(use.product(),BigInteger.ONE,BigInteger::add);save(new RareInventoryState(items,snapshot.purchaseReceipts(),uses));return status(phase);});}
|
||||
public java.util.List<RareInventoryState.Use> unresolvedUses(){return snapshot.uses().values().stream().filter(use->use.phase()==RareInventoryState.UsePhase.RESERVED).toList();}
|
||||
private static UseStatus status(RareInventoryState.UsePhase phase){return switch(phase){case RESERVED->UseStatus.RESERVED;case COMMITTED->UseStatus.COMMITTED;case REFUNDED->UseStatus.REFUNDED;};}
|
||||
private static Map<UUID,Map<String,BigInteger>> mutableItems(Map<UUID,Map<String,BigInteger>> source){var result=new LinkedHashMap<UUID,Map<String,BigInteger>>();for(var entry:source.entrySet())result.put(entry.getKey(),new LinkedHashMap<>(entry.getValue()));return result;}
|
||||
private void save(RareInventoryState state)throws IOException{repository.save(state);snapshot=state;}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> action){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Rare inventory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(action.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,23 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable typed character inventory and consume-once operation proof. */
|
||||
public record RareInventoryState(Map<UUID,Map<String,BigInteger>> items,
|
||||
Map<UUID,PurchaseReceipt> purchaseReceipts,
|
||||
Map<UUID,Use> uses){
|
||||
public enum UsePhase{RESERVED,COMMITTED,REFUNDED}
|
||||
public record PurchaseReceipt(UUID character,String product){public PurchaseReceipt{Objects.requireNonNull(character);if(product==null||product.isBlank())throw new IllegalArgumentException("Invalid product");}}
|
||||
public record Use(UUID operation,UUID character,String product,BigInteger expectedQuantity,
|
||||
BigDecimal healthBefore,BigDecimal maximum,int percent,UsePhase phase){
|
||||
public Use{Objects.requireNonNull(operation);Objects.requireNonNull(character);Objects.requireNonNull(product);Objects.requireNonNull(expectedQuantity);Objects.requireNonNull(healthBefore);Objects.requireNonNull(maximum);Objects.requireNonNull(phase);if(expectedQuantity.signum()<=0||healthBefore.signum()<0||maximum.signum()<=0||healthBefore.compareTo(maximum)>=0||percent<=0||percent>100)throw new IllegalArgumentException("Invalid rare item use");}
|
||||
}
|
||||
public RareInventoryState(){this(Map.of(),Map.of(),Map.of());}
|
||||
public RareInventoryState{var itemCopy=new LinkedHashMap<UUID,Map<String,BigInteger>>();for(var entry:items.entrySet()){var values=new LinkedHashMap<String,BigInteger>();for(var value:entry.getValue().entrySet()){if(value.getKey().isBlank()||value.getValue().signum()<0)throw new IllegalArgumentException("Invalid inventory");if(value.getValue().signum()>0)values.put(value.getKey(),value.getValue());}if(!values.isEmpty())itemCopy.put(entry.getKey(),Map.copyOf(values));}items=Map.copyOf(itemCopy);purchaseReceipts=Map.copyOf(purchaseReceipts);uses=Map.copyOf(uses);}
|
||||
public BigInteger quantity(UUID character,String product){return items.getOrDefault(character,Map.of()).getOrDefault(product,BigInteger.ZERO);}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Serialized recoverable authority for the separate shared rare catalog. */
|
||||
public final class RareMarket implements AutoCloseable {
|
||||
public enum PurchaseStatus{APPLIED,SOLD_OUT,STALE,INSUFFICIENT,UNAVAILABLE,FAILED}
|
||||
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){}
|
||||
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private volatile boolean initialized;private volatile Consumer<RareCatalog> listener=ignored->{};private boolean closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public RareMarket(RareCatalogRepository catalogs,RarePurchaseOperationStore operations,CharacterStorage characters,RareInventoryDirectory inventory,Supplier<RareMarketRules> rules,Clock clock,RareCatalogGenerator.InclusiveRoll random){this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);this.characters=Objects.requireNonNull(characters);this.inventory=Objects.requireNonNull(inventory);this.rules=Objects.requireNonNull(rules);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);readiness=submit(()->{initialize();return null;});}
|
||||
public void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
|
||||
public CompletionStage<RareCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{var current=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!current.transactions().isEmpty())throw new IOException("Rare purchases are unresolved");var generated=RareCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),RareCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);persist(generated,true);return generated;}));}
|
||||
public static Instant nextScheduledRestock(Instant now){return now.atZone(ZoneOffset.UTC).toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
|
||||
public CompletionStage<PurchaseOutcome> purchase(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product){return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,expectedCatalogRevision,product)));}
|
||||
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);initialized=true;}
|
||||
private PurchaseOutcome purchaseBlocking(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product)throws Exception{var catalog=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);if(!catalog.revision().equals(expectedCatalogRevision))return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);if(catalog.offer(product).remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);var plan=new RarePurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,catalog.revision(),product,catalog.offer(product).price());operations.prepare(plan).toCompletableFuture().join();return execute(plan);}
|
||||
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog);}
|
||||
private RareCatalog ensureCurrent(RareCatalog.RefreshReason reason)throws IOException{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(snapshot!=null&&snapshot.day().equals(today))return snapshot;if(snapshot!=null&&!snapshot.transactions().isEmpty())throw new IOException("Rare purchases unresolved at rollover");var generated=RareCatalogGenerator.generate(today,clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);persist(generated,true);return generated;}
|
||||
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify&&initialized)try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Rare market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)closeOperations();}private void closeOperations(){if(operationsClosing)return;operationsClosing=true;operations.close();operations.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)closeOperations();}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Validated rare-market settings captured by catalog generation or item use. */
|
||||
public record RareMarketRules(Map<RareProduct,BigInteger> basePrices,int variancePercent,
|
||||
Map<RareProduct,StockRange> stock,Map<RareProduct,Integer> healingPercent) {
|
||||
public record StockRange(int minimum,int maximum){public StockRange{if(minimum<0||maximum<minimum)throw new IllegalArgumentException("Invalid stock range");}}
|
||||
public RareMarketRules {
|
||||
Objects.requireNonNull(basePrices);Objects.requireNonNull(stock);Objects.requireNonNull(healingPercent);
|
||||
if(variancePercent<0||variancePercent>100)throw new IllegalArgumentException("Invalid variance");
|
||||
var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);var ranges=new EnumMap<RareProduct,StockRange>(RareProduct.class);var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);
|
||||
for(var product:RareProduct.values()){
|
||||
var price=Objects.requireNonNull(basePrices.get(product),"Missing price");if(price.signum()<0)throw new IllegalArgumentException("Negative price");prices.put(product,price);
|
||||
ranges.put(product,Objects.requireNonNull(stock.get(product),"Missing stock"));
|
||||
int percent=Objects.requireNonNull(healingPercent.get(product),"Missing healing");if(percent<0||percent>100)throw new IllegalArgumentException("Invalid healing percent");healing.put(product,percent);
|
||||
}
|
||||
basePrices=Map.copyOf(prices);stock=Map.copyOf(ranges);healingPercent=Map.copyOf(healing);
|
||||
}
|
||||
public static RareMarketRules defaults(){var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);var stock=new EnumMap<RareProduct,StockRange>(RareProduct.class);var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);for(var product:RareProduct.values()){prices.put(product,BigInteger.valueOf(product.basePrice()));stock.put(product,new StockRange(0,product.maximumStock()));healing.put(product,product.defaultHealingPercent());}return new RareMarketRules(prices,10,stock,healing);}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import java.util.*;
|
||||
/** Durable desired location for one administrator-managed Rare Items. */
|
||||
public record RareMerchant(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){public RareMerchant{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 Rare Items");}}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import java.util.*;import java.util.function.BiConsumer;import org.bukkit.*;import org.bukkit.entity.*;import org.bukkit.event.*;import org.bukkit.persistence.PersistentDataType;import org.bukkit.plugin.Plugin;
|
||||
/** Loaded-chunk Rare merchant reconciliation and native protection, distinct from Party Registrars. */
|
||||
public final class RareMerchantController implements Listener,AutoCloseable{
|
||||
private final Plugin plugin;private final RareMerchantDirectory 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 RareMerchantController(Plugin plugin,RareMerchantDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"rare_merchant_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_rare_merchant".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,RareMerchant>();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 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(RareMerchant 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,RareMerchant value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,"fantasy_rare_merchant");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()?"Rare Items":value.name()));villager.setCustomNameVisible(true);}private Location location(RareMerchant 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());}
|
||||
@Override public void close(){closed=true;for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity))entity.remove();}}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import games.dmg.fantasystick.persistence.YamlRareMerchantRepository;import java.io.*;import java.util.*;import java.util.concurrent.*;
|
||||
/** Serialized durable Fantasy Rare merchant desired state. */
|
||||
public final class RareMerchantDirectory implements AutoCloseable{
|
||||
private final YamlRareMerchantRepository repository;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-rare-merchant-persistence"));private volatile List<RareMerchant> snapshot;private final Map<UUID,Integer> leases=new HashMap<>();private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();public RareMerchantDirectory(YamlRareMerchantRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}public List<RareMerchant> all(){return snapshot;}public Optional<RareMerchant> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<RareMerchant> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new RareMerchant(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<RareMerchant>> 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 Rare merchant has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<RareMerchant> restore(RareMerchant value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Rare merchant 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 RareMerchant shop(){return find(id).orElseThrow();}@Override public void close(){synchronized(RareMerchantDirectory.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("Rare merchant 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,32 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RareSettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.session.NativeHealthLimit;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Exact-character potion reservation, persisted health, native publication and consume-once proof. */
|
||||
public final class RarePotionService {
|
||||
public enum Status{APPLIED,FULL_HEALTH,NO_EFFECT,NOT_AVAILABLE,STALE,FAILED}
|
||||
public record Outcome(Status status,BigDecimal healed){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record){}
|
||||
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
|
||||
public RarePotionService(RareInventoryDirectory inventory,RareSettings settings,CharacterStorage characters,EquipmentSessions sessions,Consumer<EntityRegainHealthEvent> healingEvents,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.healingEvents=Objects.requireNonNull(healingEvents);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
|
||||
/** Ambiguous pre-restart native delivery is consumed but never replayed onto a replacement holder. */
|
||||
public void install(){for(var use:inventory.unresolvedUses())inventory.commitUse(use.operation()).exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});}
|
||||
public BigInteger quantity(UUID character,RareProduct product){return inventory.quantity(character,product);}
|
||||
public void use(Player player,RareProduct product,Consumer<Outcome> result){var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,accepted,saved,saveFailure,result)));});}
|
||||
private void complete(Player player,Context captured,UUID operation,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
|
||||
private Optional<Context> context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);return record==null?Optional.empty():Optional.of(new Context(active,record));}
|
||||
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Products whose complete purchase and use capability is currently delivered. */
|
||||
public enum RareProduct {
|
||||
LESSER_HEALTH_POTION("lesser-health-potion", "Lesser Health Potion", 50, 10, 25),
|
||||
HEALTH_POTION("health-potion", "Health Potion", 150, 5, 50),
|
||||
GREATER_HEALTH_POTION("greater-health-potion", "Greater Health Potion", 500, 2, 100);
|
||||
|
||||
private final String id;
|
||||
private final String displayName;
|
||||
private final int basePrice;
|
||||
private final int maximumStock;
|
||||
private final int defaultHealingPercent;
|
||||
|
||||
RareProduct(String id, String displayName, int basePrice, int maximumStock, int defaultHealingPercent) {
|
||||
this.id=id;this.displayName=displayName;this.basePrice=basePrice;
|
||||
this.maximumStock=maximumStock;this.defaultHealingPercent=defaultHealingPercent;
|
||||
}
|
||||
public String id(){return id;}
|
||||
public String displayName(){return displayName;}
|
||||
public int basePrice(){return basePrice;}
|
||||
public int maximumStock(){return maximumStock;}
|
||||
public int defaultHealingPercent(){return defaultHealingPercent;}
|
||||
public static Optional<RareProduct> find(String id){return Arrays.stream(values()).filter(value->value.id.equals(id)).findFirst();}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Living active-character boundary for the one recoverable shared rare market. */
|
||||
public final class RareShop {
|
||||
public enum Status{APPLIED,INVALID_SESSION,SOLD_OUT,INSUFFICIENT,STALE,FAILED}
|
||||
public record Outcome(Status status,Optional<CharacterRecord> record){}
|
||||
public record View(CharacterRecord record,UUID session,RareCatalog catalog){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record,RareMerchant shop){}
|
||||
private final RareMerchantDirectory shops;private final EquipmentSessions sessions;private final RareMarket market;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
|
||||
public RareShop(RareMerchantDirectory shops,EquipmentSessions sessions,RareMarket market,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.shops=Objects.requireNonNull(shops);this.sessions=Objects.requireNonNull(sessions);this.market=Objects.requireNonNull(market);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
|
||||
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}public Instant now(){return market.now();}
|
||||
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){var captured=context(player,shopId);if(captured.isEmpty()){result.accept(Optional.empty());return;}market.current().whenComplete((catalog,failure)->{if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}publish(()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))result.accept(Optional.empty());else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));});});}
|
||||
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();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,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());result.accept(new Outcome(status,purchase.record()));});});}
|
||||
private Optional<Context> context(Player player,UUID shopId){var shop=shops.find(shopId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(shop==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(shop.world()))return Optional.empty();double x=location.getX()-shop.x(),y=location.getY()-shop.y(),z=location.getZ()-shop.z();return x*x+y*y+z*z<=36?Optional.of(new Context(active,record,shop)):Optional.empty();}
|
||||
private void publishHeld(RareMerchantDirectory.Lease lease,Runnable action){try{nextTick.accept(()->{try{action.run();}finally{lease.close();}});}catch(RuntimeException failure){lease.close();report(failure);}}private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Bounded nonexportable rare-shop views with authoritative stale-generation revalidation. */
|
||||
public final class RareShopMenus implements Listener,AutoCloseable {
|
||||
private record Holder(UUID shop,UUID character,UUID session,UUID catalog)implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final RareShop service;private final org.bukkit.Server server;private final NamespacedKey productKey;private boolean closed;
|
||||
public RareShopMenus(Plugin plugin,RareShop service){this.service=Objects.requireNonNull(service);server=plugin.getServer();productKey=new NamespacedKey(plugin,"rare_shop_product");}
|
||||
public void open(Player player,UUID shop){service.view(player,shop,view->{if(closed||!player.isOnline())return;if(view.isEmpty()){player.sendMessage("Rare Items shop requires a living active character beside the registered merchant.");return;}var current=view.orElseThrow();var inventory=server.createInventory(new Holder(shop,current.record().id(),current.session(),current.catalog().revision()),27,net.kyori.adventure.text.Component.text("Rare Items"));int slot=10;boolean available=false;for(var product:RareProduct.values()){var offer=current.catalog().offer(product);inventory.setItem(slot++,item(product,offer));if(offer.remaining()>0)available=true;}inventory.setItem(22,schedule(current.catalog(),service.now()));if(!available)inventory.setItem(13,empty());player.openInventory(inventory);});}
|
||||
private ItemStack item(RareProduct product,RareCatalog.Offer offer){var item=new ItemStack(Material.POTION);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text((offer.remaining()==0?"Sold out — ":"Buy ")+product.displayName()+" — "+offer.price()+" coins"));meta.lore(List.of(net.kyori.adventure.text.Component.text("Stock: "+offer.remaining())));meta.getPersistentDataContainer().set(productKey,PersistentDataType.STRING,product.id());item.setItemMeta(meta);return item;}
|
||||
private static ItemStack empty(){var item=new ItemStack(Material.BARRIER);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text("All Rare Items are sold out"));item.setItemMeta(meta);return item;}
|
||||
private static ItemStack schedule(RareCatalog catalog,Instant now){var next=catalog.day().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();long seconds=Math.max(0,Duration.between(now,next).toSeconds()),hours=seconds/3600,minutes=(seconds%3600)/60;var item=new ItemStack(Material.CLOCK);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text("Restock in: "+hours+"h "+minutes+"m"));item.setItemMeta(meta);return item;}
|
||||
public void refreshOpen(){if(closed)return;for(var player:server.getOnlinePlayers()){var view=player.getOpenInventory();if(view==null)continue;var inventory=view.getTopInventory();if(inventory!=null&&inventory.getHolder() instanceof Holder holder)open(player,holder.shop());}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(event.getRawSlot()<0||event.getRawSlot()>=event.getInventory().getSize())return;if(closed||!service.active(player,holder.shop()).map(value->value.id().equals(holder.character())).orElse(false)||!service.activeSession(player,holder.shop()).map(holder.session()::equals).orElse(false)){player.sendMessage("Rare purchase refused: the character, session or merchant changed.");return;}var item=event.getCurrentItem();if(item==null)return;String id=item.getItemMeta().getPersistentDataContainer().get(productKey,PersistentDataType.STRING);var product=RareProduct.find(id).orElse(null);if(product==null)return;service.buy(player,holder.shop(),holder.catalog(),product,outcome->{if(outcome.status()==RareShop.Status.APPLIED)player.sendMessage("Purchased "+product.displayName()+" for character Inventory.");else{player.sendMessage("Rare purchase refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");if(outcome.status()==RareShop.Status.STALE||outcome.status()==RareShop.Status.SOLD_OUT)open(player,holder.shop());}});}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
|
||||
@Override public void close(){closed=true;}
|
||||
}
|
||||
@@ -28,6 +28,19 @@ gear-stock-diamond-min: 5
|
||||
gear-stock-diamond-max: 10
|
||||
gear-stock-netherite-min: 1
|
||||
gear-stock-netherite-max: 3
|
||||
rare-price-variance-percent: 10
|
||||
rare-price-lesser-health-potion: 50
|
||||
rare-stock-lesser-health-potion-min: 0
|
||||
rare-stock-lesser-health-potion-max: 10
|
||||
rare-lesser-health-potion-healing-percent: 25
|
||||
rare-price-health-potion: 150
|
||||
rare-stock-health-potion-min: 0
|
||||
rare-stock-health-potion-max: 5
|
||||
rare-health-potion-healing-percent: 50
|
||||
rare-price-greater-health-potion: 500
|
||||
rare-stock-greater-health-potion-min: 0
|
||||
rare-stock-greater-health-potion-max: 2
|
||||
rare-greater-health-potion-healing-percent: 100
|
||||
sheet-resource-pack-url: ''
|
||||
sheet-resource-pack-sha1: ''
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class ChampionQuestNativeTest {
|
||||
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));when(click.isRightClick()).thenReturn(true);controller.click(click);drainOne(f);
|
||||
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var soloQuest=directory.activeFor(solo.core.record.id(),null).orElseThrow();assertTrue(directory.bank(soloQuest.id(),UUID.randomUUID(),Map.of(solo.core.record.id(),new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(7),java.math.BigInteger.valueOf(3)))).get(5,java.util.concurrent.TimeUnit.SECONDS));var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind());verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);actor.core.location.setX(100);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind(),"death processing retains immutable managed origin authority");assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty());i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());assertEquals(initial.restrictedKills(),saved.restrictedKills());assertEquals(memberInitial.restrictedKills(),savedMember.restrictedKills());var partyTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,partyTrophy.type());assertEquals("Summit",partyTrophy.origin());assertEquals(java.math.BigInteger.valueOf(150),partyTrophy.total().xp());assertEquals(java.math.BigInteger.valueOf(40),partyTrophy.total().coins());assertFalse(partyTrophy.rewards().containsKey(actor.core.record.id()),"absent leader custody does not invent a beneficiary share");assertTrue(partyTrophy.rewards().containsKey(member.core.record.id()));assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
|
||||
awaitPulse(f,()->spawned.stream().filter(Vindicator.class::isInstance).count()>=2);assertEquals(memberParticlesAfterCompletion,member.particleCalls.size(),"completed party members stop receiving the shared boundary");var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());var soloTrophy=trophies.trophies(solo.core.record.id()).getFirst();assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),soloTrophy.total());assertEquals(solo.core.record.id(),soloTrophy.carrier());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
|
||||
awaitPulse(f,()->spawned.stream().filter(Vindicator.class::isInstance).count()>=2);assertEquals(memberParticlesAfterCompletion,member.particleCalls.size(),"completed party members stop receiving the shared boundary");var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(solo.core.record.id()).isEmpty());var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());var soloTrophy=trophies.trophies(solo.core.record.id()).getFirst();assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),soloTrophy.total());assertEquals(solo.core.record.id(),soloTrophy.carrier());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
|
||||
PartyBookNativeTest.open(f,actor);var inventoryText=PartyBookNativeTest.text(actor).replace("\n","");assertTrue(inventoryText.contains("Daybreak Champion Head: 1"),inventoryText);assertTrue(inventoryText.contains("Trophy origin: Summit"),inventoryText);
|
||||
var reroll=directory.rerollToday(catalog.revision()).get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(reroll.catalog().isPresent());assertTrue(controller.publishPendingReset().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));assertTrue(directory.removeSite(site).get(5,java.util.concurrent.TimeUnit.SECONDS).isPresent());assertTrue(parties.disband(actor.core.id,party.id()));doReturn(false).when(member.player).isOnline();
|
||||
var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");turn.open(actor.player);var turnMenu=actor.inventories.getLast();var redeem=menuItem(turnMenu,"Redeem Daybreak Champion Head");assertTrue(redeem.getItemMeta().lore().toString().contains("Beneficiaries: 1"));click(turn,actor,turnMenu,redeem);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(member.core.record.id()).xp().equals(java.math.BigInteger.valueOf(150)));var memberShare=trophies.unclaimed(member.core.record.id());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()),"carrier custody does not claim or invent a share");assertEquals(memberShare,trophies.unclaimed(member.core.record.id()),"offline beneficiary retains the full frozen pool");doReturn(true).when(member.player).isOnline();turn.open(member.player);var memberTurn=member.inventories.getLast();click(turn,member,memberTurn,menuItem(memberTurn,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(member.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var claimedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(memberInitial.experience().add(java.math.BigInteger.valueOf(150)),claimedMember.experience());assertEquals(memberInitial.coins().add(java.math.BigInteger.valueOf(40)),claimedMember.coins());turn.open(solo.player);var soloTurn=solo.inventories.getLast();click(turn,solo,soloTurn,menuItem(soloTurn,"Claim retained adventure rewards"));await(f,()->directory.unclaimedAll(solo.core.record.id()).equals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty()));assertEquals(1,trophies.trophies(solo.core.record.id()).size(),"legacy claims do not consume an earned trophy");
|
||||
|
||||
@@ -118,8 +118,8 @@ class DungeonNativeTest {
|
||||
var spawned=new ArrayList<Vindicator>();var maximumBases=new IdentityHashMap<Vindicator,double[]>();var attackBases=new IdentityHashMap<Vindicator,double[]>();var bossEffects=new IdentityHashMap<Vindicator,Set<org.bukkit.potion.PotionEffect>>();var bossEquipment=new IdentityHashMap<Vindicator,org.bukkit.inventory.EntityEquipment>();
|
||||
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getType()).thenReturn(EntityType.VINDICATOR);when(mob.getWorld()).thenReturn(admin.world);when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
|
||||
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());
|
||||
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");await(f,()->adventures.snapshot().catalog()!=null&&adventures.snapshot().catalog().sites().contains(dungeon.id()),admin.messages);
|
||||
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
|
||||
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
|
||||
var sender=new Sender();
|
||||
assertEquals(List.of("adventure-site","config","dungeon","experience","give","inspect","party-registrar","quest-giver","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("adventure-site","config","dungeon","experience","give","inspect","party-registrar","quest-giver","rare-shop","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
|
||||
@@ -122,7 +122,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
|
||||
var sender = new Sender();
|
||||
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
|
||||
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
|
||||
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
|
||||
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
|
||||
|
||||
@@ -116,7 +116,7 @@ class FantasyHealthNativeTest {
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(defeated.player, source,
|
||||
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(death); awaitTail(f, defeated);
|
||||
assertTrue(death.isCancelled());
|
||||
assertEquals(0, receivedDust(defeated, org.bukkit.Color.RED));
|
||||
assertEquals(1, receivedDust(viewer, org.bukkit.Color.RED));
|
||||
@@ -125,6 +125,8 @@ class FantasyHealthNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitTail(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{for(int attempt=0;attempt<20;attempt++){PartyBookNativeTest.drain(f);var item=actor.core.inventory.getItemInOffHand();if(item!=null&&item.getType()==org.bukkit.Material.WRITTEN_BOOK)return;Thread.sleep(5);}fail("Timed out waiting for durable Tail publication");}
|
||||
|
||||
@Test void uncappedConstitutionEntersAtTheNativeCeilingAndPersistsOnlyBoundedRemainingHealth() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(16, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/adventure/fishing/dungeon refresh and session particles");
|
||||
assertEquals(18, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/rare/adventure/fishing/dungeon refresh and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
|
||||
@@ -346,7 +346,7 @@ class LongbowNativeTest {
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
|
||||
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
|
||||
assertEquals(java.util.List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
|
||||
|
||||
@@ -200,7 +200,7 @@ class QuickstepNativeTest {
|
||||
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
|
||||
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
|
||||
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import games.dmg.fantasystick.shop.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RareShopNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test void registeredMerchantPurchaseInventoryUseAndNativeHealingUseConnectedPath()throws Exception{
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-price-variance-percent: 0\nrare-price-health-potion: 40\nrare-stock-health-potion-min: 1\nrare-stock-health-potion-max: 1\n");
|
||||
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");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);int x=actor.core.location.getBlockX()>>4,z=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(x);when(chunk.getZ()).thenReturn(z);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(x,z)).thenReturn(chunk);when(actor.world.isChunkLoaded(x,z)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());
|
||||
assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"rare-shop",""}));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-"}).contains("rare-price-health-potion"));
|
||||
var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","place","Curios"});await(f,()->((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).all().size()==1);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var shop=directory.all().getFirst();assertEquals("Curios",shop.name());var controller=(RareMerchantController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchantController");assertTrue(controller.tagged(villager));
|
||||
var repository=new YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(100),true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear()),old.revision());f.activate(actor);
|
||||
int opened=actor.inventories.size(),books=actor.books.size();var main=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);var offhand=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(main);f.dispatcher.accept(offhand);assertTrue(main.isCancelled());assertTrue(offhand.isCancelled());await(f,()->actor.inventories.size()==opened+1);assertEquals(books,actor.books.size());var menu=actor.inventories.getLast();var potion=menu.getItem(11);assertEquals(Material.POTION,potion.getType());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(potion.getItemMeta().displayName()).contains("Health Potion — 40 coins"));assertEquals("Stock: 1",net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(potion.getItemMeta().lore().getFirst()));
|
||||
var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");var click=mock(InventoryClickEvent.class);when(click.getInventory()).thenReturn(menu);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getCurrentItem()).thenReturn(potion);menus.click(click);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).equals(BigInteger.ONE));var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(60))).orElse(false));var active=sessions.active(actor.core.id).orElseThrow(()->new AssertionError("Purchase removed active session: "+actor.messages));var record=sessions.currentRecord(active).orElseThrow(()->new AssertionError("Purchase left no current record: "+actor.messages));var injured=characters.saveHealth(record.id(),record.revision(),BigDecimal.valueOf(5),BigDecimal.valueOf(100)).join().orElseThrow(()->new AssertionError("Injury save was refused"));assertTrue(sessions.acknowledgeProgress(active,record.revision(),injured));actor.player.setHealth(5);
|
||||
PartyBookNativeTest.open(f,actor);assertTrue(PartyBookNativeTest.text(actor).contains("Health Potion"),PartyBookNativeTest.text(actor));assertTrue(PartyBookNativeTest.text(actor).contains("[Use]"),PartyBookNativeTest.text(actor));var action=PartyBookNativeTest.button(actor,"[Use]");PartyBookNativeTest.click(f,actor,action);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).signum()==0&&actor.player.getHealth()>5&&actor.messages.stream().anyMatch(message->message.contains("Used Health Potion")));assertEquals(10d,actor.player.getHealth());assertEquals(0,BigDecimal.TEN.compareTo(characters.find(record.id()).join().orElseThrow().health()));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Used Health Potion")&&message.contains("restored")));
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();f.plugin.setEnabled(true);var reloaded=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION));assertTrue(((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).find(shop.id()).isPresent());
|
||||
}
|
||||
}
|
||||
@Test void everyMerchantSharesOneAllZeroCatalogAndShowsAnEmptyShop()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-stock-lesser-health-potion-min: 0\nrare-stock-lesser-health-potion-max: 0\nrare-stock-health-potion-min: 0\nrare-stock-health-potion-max: 0\nrare-stock-greater-health-potion-min: 0\nrare-stock-greater-health-potion-max: 0\n");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);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var first=directory.place("One",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var second=directory.place("Two",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");menus.open(actor.player,first.id());await(f,()->!actor.inventories.isEmpty());var one=actor.inventories.getLast();assertEquals(Material.BARRIER,one.getItem(13).getType());menus.open(actor.player,second.id());await(f,()->actor.inventories.size()>=2);var two=actor.inventories.getLast();assertEquals(one.getItem(10).getItemMeta().displayName(),two.getItem(10).getItemMeta().displayName());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(10).getItemMeta().displayName()).startsWith("Sold out"));}}
|
||||
|
||||
@Test void allPotionSizesPreserveFractionalHealingAndFullHealthStock()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);f.activate(actor);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");inventory.add(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,new BigDecimal("4"));useFirst(f,actor);awaitEvidence(f,()->actor.player.getHealth()==6.5&&inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION).signum()==0,()->"lesser health="+actor.player.getHealth()+" quantity="+inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION)+" messages="+actor.messages);inventory.add(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,BigDecimal.TEN);var potionService=(RarePotionService)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rarePotions");var refusal=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.LESSER_HEALTH_POTION,refusal::set);assertEquals(RarePotionService.Status.FULL_HEALTH,refusal.get().status());assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));var settings=(RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings");var zero=settings.set("rare-lesser-health-potion-healing-percent","0").toCompletableFuture().join();assertTrue(settings.publish(zero));injure(actor,characters,sessions,BigDecimal.ONE);var noEffect=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.LESSER_HEALTH_POTION,noEffect::set);assertEquals(RarePotionService.Status.NO_EFFECT,noEffect.get().status());assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));inventory.add(actor.core.record.id(),RareProduct.GREATER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,BigDecimal.ONE);var greater=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.GREATER_HEALTH_POTION,greater::set);awaitEvidence(f,()->greater.get()!=null&&greater.get().status()==RarePotionService.Status.APPLIED&&actor.player.getHealth()==10&&inventory.quantity(actor.core.record.id(),RareProduct.GREATER_HEALTH_POTION).signum()==0,()->"greater outcome="+greater.get()+" health="+actor.player.getHealth()+" quantities="+inventory.inventory(actor.core.record.id())+" messages="+actor.messages);assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));}}
|
||||
private static void injure(FantasyStickLifecycleNativeTest.Actor actor,CharacterStorage characters,games.dmg.fantasystick.session.EquipmentSessions sessions,BigDecimal health){var active=sessions.active(actor.core.id).orElseThrow();var record=sessions.currentRecord(active).orElseThrow();var saved=characters.saveHealth(record.id(),record.revision(),health,BigDecimal.valueOf(100)).join().orElseThrow();if(!saved.revision().equals(record.revision()))assertTrue(sessions.acknowledgeProgress(active,record.revision(),saved));actor.player.setHealth(health.doubleValue());}
|
||||
private static void useFirst(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));}
|
||||
private static void awaitEvidence(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition,java.util.function.Supplier<String> evidence)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean(),evidence);}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class SecondWindNativeTest {
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
|
||||
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
|
||||
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareProduct;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RareSettingsTest {
|
||||
@TempDir Path data;
|
||||
@Test void validatesRangesAndPublishesOnlyAcknowledgedWritesWhilePreservingOtherYaml()throws Exception{
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");
|
||||
try(var settings=new RareSettings(data,Map.of())){
|
||||
assertThrows(IllegalArgumentException.class,()->settings.set("rare-stock-health-potion-min","6"));
|
||||
assertThrows(IllegalArgumentException.class,()->settings.set("rare-health-potion-healing-percent","101"));
|
||||
var saved=settings.set("rare-price-health-potion","175").toCompletableFuture().join();
|
||||
assertEquals("150",settings.value("rare-price-health-potion"));
|
||||
assertTrue(settings.publish(saved));assertEquals("175",settings.value("rare-price-health-potion"));
|
||||
assertEquals(java.math.BigInteger.valueOf(175),settings.rules().basePrices().get(RareProduct.HEALTH_POTION));
|
||||
}
|
||||
var text=java.nio.file.Files.readString(data.resolve("config.yml"));assertTrue(text.contains("unrelated: keep"));assertTrue(text.contains("rare-price-health-potion: 175"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.EnumMap;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RareCatalogGeneratorTest {
|
||||
@Test void independentlyGeneratesInclusiveZeroStockAndExactCeilingPrices() {
|
||||
var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);
|
||||
var stock=new EnumMap<RareProduct,RareMarketRules.StockRange>(RareProduct.class);
|
||||
var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);
|
||||
for(var product:RareProduct.values()){prices.put(product,BigInteger.valueOf(product.basePrice()));stock.put(product,new RareMarketRules.StockRange(0,product.maximumStock()));healing.put(product,product.defaultHealingPercent());}
|
||||
var rules=new RareMarketRules(prices,10,stock,healing);
|
||||
int[] call={0};
|
||||
var catalog=RareCatalogGenerator.generate(LocalDate.of(2026,9,23),Instant.parse("2026-09-23T12:00:00Z"),RareCatalog.RefreshReason.STARTUP,rules,(minimum,maximum)->call[0]++%2==0?maximum:minimum);
|
||||
for(var product:RareProduct.values()){
|
||||
var offer=catalog.offer(product);
|
||||
assertEquals(BigInteger.valueOf(product.basePrice()).multiply(BigInteger.valueOf(11000)).add(BigInteger.valueOf(9999)).divide(BigInteger.valueOf(10000)),offer.price());
|
||||
assertEquals(0,offer.remaining());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void zeroBasePriceStillCostsOneCoinAndEveryProductCanStockZero() {
|
||||
var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);var stock=new EnumMap<RareProduct,RareMarketRules.StockRange>(RareProduct.class);var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);
|
||||
for(var product:RareProduct.values()){prices.put(product,BigInteger.ZERO);stock.put(product,new RareMarketRules.StockRange(0,0));healing.put(product,product.defaultHealingPercent());}
|
||||
var catalog=RareCatalogGenerator.generate(LocalDate.of(2026,9,23),Instant.EPOCH,RareCatalog.RefreshReason.ADMINISTRATIVE,new RareMarketRules(prices,100,stock,healing),(minimum,maximum)->minimum);
|
||||
assertTrue(catalog.products().values().stream().allMatch(offer->offer.price().equals(BigInteger.ONE)&&offer.remaining()==0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.YamlRareInventoryRepository;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RareInventoryDirectoryTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void purchaseReceiptAddsExactlyOneTypedItemAcrossRetryAndRestart() throws Exception {
|
||||
UUID character=UUID.randomUUID(),operation=UUID.randomUUID();
|
||||
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
|
||||
assertEquals(RareInventoryDirectory.AddStatus.APPLIED,inventory.add(character,RareProduct.HEALTH_POTION,operation).join());
|
||||
assertEquals(RareInventoryDirectory.AddStatus.DUPLICATE,inventory.add(character,RareProduct.HEALTH_POTION,operation).join());
|
||||
assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.HEALTH_POTION));
|
||||
}
|
||||
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
|
||||
assertEquals(RareInventoryDirectory.AddStatus.DUPLICATE,inventory.add(character,RareProduct.HEALTH_POTION,operation).join());
|
||||
assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.HEALTH_POTION));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void useReservationIsBoundToQuantityAndCompensatesARefusedNativeHeal() throws Exception {
|
||||
UUID character=UUID.randomUUID(),purchase=UUID.randomUUID(),use=UUID.randomUUID();
|
||||
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
|
||||
inventory.add(character,RareProduct.LESSER_HEALTH_POTION,purchase).join();
|
||||
var reservation=inventory.reserveUse(character,RareProduct.LESSER_HEALTH_POTION,use,BigInteger.ONE,new BigDecimal("4.5"),new BigDecimal("10"),25).join();
|
||||
assertEquals(RareInventoryDirectory.UseStatus.RESERVED,reservation.status());
|
||||
assertEquals(BigInteger.ZERO,inventory.quantity(character,RareProduct.LESSER_HEALTH_POTION));
|
||||
assertEquals(RareInventoryDirectory.UseStatus.REFUNDED,inventory.refundUse(use).join());
|
||||
assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.LESSER_HEALTH_POTION));
|
||||
assertEquals(RareInventoryDirectory.UseStatus.REFUNDED,inventory.refundUse(use).join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void committedUseCannotReplayOrRecreateConsumedStock() throws Exception {
|
||||
UUID character=UUID.randomUUID(),purchase=UUID.randomUUID(),use=UUID.randomUUID();
|
||||
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
|
||||
inventory.add(character,RareProduct.GREATER_HEALTH_POTION,purchase).join();
|
||||
inventory.reserveUse(character,RareProduct.GREATER_HEALTH_POTION,use,BigInteger.ONE,BigDecimal.ONE,BigDecimal.TEN,100).join();
|
||||
assertEquals(RareInventoryDirectory.UseStatus.COMMITTED,inventory.commitUse(use).join());
|
||||
assertEquals(RareInventoryDirectory.UseStatus.COMMITTED,inventory.commitUse(use).join());
|
||||
assertEquals(BigInteger.ZERO,inventory.quantity(character,RareProduct.GREATER_HEALTH_POTION));
|
||||
}
|
||||
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
|
||||
assertEquals(RareInventoryDirectory.UseStatus.COMMITTED,inventory.commitUse(use).join());
|
||||
assertEquals(BigInteger.ZERO,inventory.quantity(character,RareProduct.GREATER_HEALTH_POTION));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.*;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RareMarketTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void purchaseRecoverablyDebitsOnePriceStockAndAddsOneTypedItemAcrossRestart() throws Exception {
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0","rare-price-health-potion","40","rare-stock-health-potion-min","1","rare-stock-health-potion-max","1"));var repository=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var character=new CharacterRecord(base.id(),base.name(),base.revision(),base.stats(),base.level(),base.experience(),base.unspentPoints(),base.health(),base.learned(),base.loadout(),base.passives(),base.cooldowns(),base.restrictedKills(),false,null,null,BigInteger.valueOf(100),true,java.util.Set.of(),BigInteger.ZERO,CharacterGear.none());
|
||||
try(var characters=new CharacterStorage(repository,clock);var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var market=new RareMarket(new YamlRareCatalogRepository(data),new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
characters.create(character).join();var catalog=market.current().toCompletableFuture().join();var outcome=market.purchase(character.id(),character.revision(),catalog.revision(),RareProduct.HEALTH_POTION).toCompletableFuture().join();assertEquals(RareMarket.PurchaseStatus.APPLIED,outcome.status());assertEquals(BigInteger.valueOf(60),outcome.record().orElseThrow().coins());assertEquals(0,outcome.catalog().offer(RareProduct.HEALTH_POTION).remaining());assertEquals(BigInteger.ONE,inventory.quantity(character.id(),RareProduct.HEALTH_POTION));
|
||||
}
|
||||
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){assertEquals(BigInteger.ONE,inventory.quantity(character.id(),RareProduct.HEALTH_POTION));}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test void restartFinishesDebitInventoryAndStockAfterSettlementFailureWithoutDuplication()throws Exception{var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0","rare-price-health-potion","40","rare-stock-health-potion-min","1","rare-stock-health-potion-max","1"));var charactersRepository=new YamlCharacterRepository(data);var character=withCoins(new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults()));var durable=new YamlRareCatalogRepository(data);class Failing implements RareCatalogRepository{int saves;public java.util.Optional<RareCatalog> load()throws java.io.IOException{return durable.load();}public void save(RareCatalog catalog)throws java.io.IOException{if(++saves==3)throw new java.io.IOException("injected settlement failure");durable.save(catalog);}}try(var characters=new CharacterStorage(charactersRepository,clock);var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){characters.create(character).join();try(var market=new RareMarket(new Failing(),new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,(minimum,maximum)->minimum)){var catalog=market.current().toCompletableFuture().join();assertThrows(java.util.concurrent.CompletionException.class,()->market.purchase(character.id(),character.revision(),catalog.revision(),RareProduct.HEALTH_POTION).toCompletableFuture().join());}}try(var characters=new CharacterStorage(charactersRepository,clock);var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var market=new RareMarket(durable,new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,(minimum,maximum)->maximum)){var catalog=market.current().toCompletableFuture().join();assertEquals(BigInteger.valueOf(60),characters.find(character.id()).join().orElseThrow().coins());assertEquals(BigInteger.ONE,inventory.quantity(character.id(),RareProduct.HEALTH_POTION));assertEquals(0,catalog.offer(RareProduct.HEALTH_POTION).remaining());assertTrue(catalog.transactions().isEmpty());}finally{settings.close();}}
|
||||
|
||||
@Test void competingBuyersCannotOversellTheLastSharedUnit()throws Exception{var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0","rare-price-health-potion","40","rare-stock-health-potion-min","1","rare-stock-health-potion-max","1"));var repository=new YamlCharacterRepository(data);var factory=new games.dmg.fantasystick.crafting.CharacterFactory();var a=withCoins(factory.create(games.dmg.fantasystick.progression.ProgressionRules.defaults()));var b=withCoins(factory.create(games.dmg.fantasystick.progression.ProgressionRules.defaults()));try(var characters=new CharacterStorage(repository,clock);var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var market=new RareMarket(new YamlRareCatalogRepository(data),new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,(minimum,maximum)->minimum)){characters.create(a).join();characters.create(b).join();var catalog=market.current().toCompletableFuture().join();var first=market.purchase(a.id(),a.revision(),catalog.revision(),RareProduct.HEALTH_POTION).toCompletableFuture();var second=market.purchase(b.id(),b.revision(),catalog.revision(),RareProduct.HEALTH_POTION).toCompletableFuture();assertEquals(java.util.Set.of(RareMarket.PurchaseStatus.APPLIED,RareMarket.PurchaseStatus.SOLD_OUT),java.util.Set.of(first.join().status(),second.join().status()));assertEquals(0,market.current().toCompletableFuture().join().offer(RareProduct.HEALTH_POTION).remaining());assertEquals(BigInteger.ONE,inventory.quantity(a.id(),RareProduct.HEALTH_POTION).add(inventory.quantity(b.id(),RareProduct.HEALTH_POTION)));}finally{settings.close();}}
|
||||
|
||||
private static CharacterRecord withCoins(CharacterRecord old){return new CharacterRecord(old.id(),old.name(),old.revision(),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(100),true,java.util.Set.of(),BigInteger.ZERO,CharacterGear.none());}
|
||||
|
||||
@Test void staleGenerationAndZeroStockRefuseWithoutCoinsOrInventoryMutation() throws Exception {
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);var settings=new RareSettings(data,Map.of("rare-stock-lesser-health-potion-min","0","rare-stock-lesser-health-potion-max","0"));var repository=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var character=new CharacterRecord(base.id(),base.name(),base.revision(),base.stats(),base.level(),base.experience(),base.unspentPoints(),base.health(),base.learned(),base.loadout(),base.passives(),base.cooldowns(),base.restrictedKills(),false,null,null,BigInteger.valueOf(100),true,java.util.Set.of(),BigInteger.ZERO,CharacterGear.none());
|
||||
try(var characters=new CharacterStorage(repository,clock);var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var market=new RareMarket(new YamlRareCatalogRepository(data),new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
characters.create(character).join();var old=market.current().toCompletableFuture().join();assertEquals(RareMarket.PurchaseStatus.SOLD_OUT,market.purchase(character.id(),character.revision(),old.revision(),RareProduct.LESSER_HEALTH_POTION).toCompletableFuture().join().status());var current=market.restock().toCompletableFuture().join();assertEquals(RareMarket.PurchaseStatus.STALE,market.purchase(character.id(),character.revision(),old.revision(),RareProduct.HEALTH_POTION).toCompletableFuture().join().status());assertNotEquals(old.revision(),current.revision());assertEquals(character,characters.find(character.id()).join().orElseThrow());assertTrue(inventory.inventory(character.id()).isEmpty());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user