Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc1012ecd | ||
|
|
396baf473f | ||
|
|
1468db0533 | ||
|
|
f9ab5b6e46 |
No files matched your search
@@ -42,6 +42,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.party.PartyManagement partyManagement;
|
||||
private games.dmg.fantasystick.party.PartyRegistrarMenus partyRegistrarMenus;
|
||||
private games.dmg.fantasystick.party.PartyEvents partyEvents;
|
||||
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.ArmorShop armorShop;
|
||||
private games.dmg.fantasystick.shop.ArmorShopMenus armorShopMenus;
|
||||
private org.bukkit.scheduler.BukkitTask armorMarketPulse;
|
||||
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
|
||||
private games.dmg.fantasystick.party.PartyCombat partyCombat;
|
||||
private EquipmentAdminCommand admin;
|
||||
private volatile Object lifetime;
|
||||
@@ -104,6 +111,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
@@ -149,10 +157,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
|
||||
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)));
|
||||
}
|
||||
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 coinConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.CoinSettings.keys()) {
|
||||
coinConfiguration.put(key, getConfig().getString(key,
|
||||
@@ -215,10 +221,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
partyEconomySettings = new games.dmg.fantasystick.persistence.PartyEconomySettings(
|
||||
getDataFolder().toPath(), partyEconomyConfiguration);
|
||||
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
|
||||
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
|
||||
var factory = new CharacterFactory(coinSettings::rules);
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC(),
|
||||
coinSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
var armorRandom=java.util.random.RandomGenerator.getDefault();
|
||||
armorMarket=new games.dmg.fantasystick.shop.ArmorMarket(
|
||||
new games.dmg.fantasystick.persistence.YamlArmorCatalogRepository(getDataFolder().toPath()),
|
||||
new games.dmg.fantasystick.persistence.ArmorPurchaseOperationStore(getDataFolder().toPath()),
|
||||
characters,gearSettings::rules,Clock.systemUTC(),
|
||||
(minimum,maximum)->(int)armorRandom.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)),
|
||||
@@ -261,6 +274,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
new games.dmg.fantasystick.persistence.YamlPartyRepository(getDataFolder().toPath()), partySettings::maximum);
|
||||
partyRegistrars = new games.dmg.fantasystick.party.PartyRegistrarDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
|
||||
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
|
||||
} catch (java.io.IOException invalidPartyState) {
|
||||
throw new java.io.UncheckedIOException("Persistent party state could not be loaded", invalidPartyState);
|
||||
}
|
||||
@@ -393,6 +407,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
@@ -462,6 +477,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.accept(input);
|
||||
});
|
||||
sessionEvents.attachAbilityRegistry(abilityRegistry);
|
||||
sessionEvents.attachGear(gearItems);
|
||||
partyManagement = new games.dmg.fantasystick.party.PartyManagement(characterParties, partyRegistrars,
|
||||
characters, sessions, partyEconomySettings,
|
||||
new games.dmg.fantasystick.persistence.PartyOperationStore(getDataFolder().toPath()), nextTick,
|
||||
@@ -469,6 +485,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
partyRegistrarMenus = new games.dmg.fantasystick.party.PartyRegistrarMenus(this, partyManagement);
|
||||
partyRegistrarController = new games.dmg.fantasystick.party.PartyRegistrarController(this, partyRegistrars,
|
||||
partyRegistrarMenus::open);
|
||||
armorShop=new games.dmg.fantasystick.shop.ArmorShop(armorers,sessions,armorMarket,nextTick,
|
||||
failure->getLogger().log(Level.WARNING,"Fantasy Armorer purchase failed",failure));
|
||||
armorShopMenus=new games.dmg.fantasystick.shop.ArmorShopMenus(this,armorShop);
|
||||
var localArmorMenus=armorShopMenus;
|
||||
armorMarket.attachListener(catalog->{if(lifetime!=generation)return;try{nextTick.accept(()->{
|
||||
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);
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
@@ -495,6 +521,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachPartyEconomySettings(partyEconomySettings);
|
||||
admin.attachPartyRegistrars(partyRegistrars, partyRegistrarController);
|
||||
admin.attachGearSettings(gearSettings);
|
||||
admin.attachArmorers(armorers,armorerController);
|
||||
admin.attachArmorMarket(armorMarket);
|
||||
admin.attachCoinSettings(coinSettings);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
@@ -517,7 +546,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
getServer().getPluginManager().registerEvents(partyRegistrarController, this);
|
||||
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
|
||||
getServer().getPluginManager().registerEvents(armorerController,this);
|
||||
getServer().getPluginManager().registerEvents(armorShopMenus,this);
|
||||
partyRegistrarController.install();
|
||||
armorerController.install();
|
||||
var localArmorMarket=armorMarket;
|
||||
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
|
||||
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);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(healthSteal, this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
@@ -585,6 +622,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingPartyRegistrarController = partyRegistrarController; partyRegistrarController = null;
|
||||
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
|
||||
var closingPartyManagement = partyManagement; partyManagement = null;
|
||||
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
|
||||
var closingArmorShopMenus=armorShopMenus;armorShopMenus=null;armorShop=null;
|
||||
var closingArmorMarket=armorMarket;armorMarket=null;var closingArmorMarketPulse=armorMarketPulse;armorMarketPulse=null;
|
||||
var closingArmorMenuPulse=armorMenuPulse;armorMenuPulse=null;
|
||||
var closingCrafting = crafting; crafting = null;
|
||||
var closingGuard = guard; guard = null;
|
||||
var closingAnvil = anvilNames; anvilNames = null;
|
||||
@@ -637,6 +678,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
|
||||
var closingPartySettings = partySettings; partySettings = null;
|
||||
var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null;
|
||||
var closingGearSettings=gearSettings;gearSettings=null;
|
||||
var closingCoinSettings = coinSettings; coinSettings = null;
|
||||
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
|
||||
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
|
||||
@@ -697,6 +739,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
|
||||
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 (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
|
||||
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
|
||||
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
|
||||
@@ -711,6 +754,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingPartyRegistrars.drained().toCompletableFuture(),
|
||||
closingPartyManagement == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyManagement.drained().toCompletableFuture(),
|
||||
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(),
|
||||
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyEconomySettings.drained().toCompletableFuture(),
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -798,6 +844,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingPartyManagement != null) cleanup("party management", closingPartyManagement::close);
|
||||
if (closingPartyRegistrarController != null) cleanup("party registrar entities", closingPartyRegistrarController::close);
|
||||
if (closingPartyRegistrars != null) cleanup("party registrars", closingPartyRegistrars::close);
|
||||
if(closingArmorMarketPulse!=null)cleanup("armor market schedule",closingArmorMarketPulse::cancel);
|
||||
if(closingArmorMenuPulse!=null)cleanup("armor menu schedule",closingArmorMenuPulse::cancel);
|
||||
if(closingArmorShopMenus!=null)cleanup("armor shop menus",closingArmorShopMenus::close);
|
||||
if(closingArmorerController!=null)cleanup("armorer entities",closingArmorerController::close);
|
||||
if(closingArmorMarket!=null)cleanup("armor market",closingArmorMarket::close);
|
||||
if(closingArmorers!=null)cleanup("armorers",closingArmorers::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);
|
||||
@@ -809,6 +861,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingPartyRegistrarController);
|
||||
unregister(closingPartyRegistrarMenus);
|
||||
unregister(closingArmorerController);unregister(closingArmorShopMenus);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingHealthSteal);
|
||||
unregister(closingFireball);
|
||||
|
||||
@@ -138,6 +138,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
|
||||
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
|
||||
if(end==allOverview.size())pages.add(gearPage(record));
|
||||
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
|
||||
|
||||
var definitions = abilities.definitions().stream()
|
||||
@@ -204,6 +205,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return item;
|
||||
}
|
||||
|
||||
private Component gearPage(CharacterRecord record){return Component.text("Current gear\n\nHead: Cosmetic\nonly\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);}
|
||||
|
||||
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
|
||||
var builder = new PageBuilder();
|
||||
builder.text(record.name());
|
||||
|
||||
@@ -131,7 +131,7 @@ public final class EquipmentStorage {
|
||||
for (int slot : EQUIPMENT_SLOTS) {
|
||||
if (slot == 39) continue;
|
||||
var current = inventory.getItem(slot);
|
||||
if (!empty(current) && !(slot < 9 && ownedControl.test(current.clone()))) return RestoreResult.OCCUPIED_SLOT;
|
||||
if (!empty(current) && !ownedControl.test(current.clone())) return RestoreResult.OCCUPIED_SLOT;
|
||||
}
|
||||
var currentHead = inventory.getItem(39);
|
||||
var displacedHead = empty(currentHead) ? null : currentHead.clone();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.*;import org.bukkit.inventory.*;import org.bukkit.persistence.PersistentDataType;import org.bukkit.plugin.Plugin;
|
||||
/** Native unbreakable session-bound projection of permanent character armor. */
|
||||
public final class GearItems{
|
||||
private final NamespacedKey typeKey,ownerKey,sessionKey,slotKey,tierKey;
|
||||
public GearItems(Plugin plugin){typeKey=new NamespacedKey(plugin,"character_gear");ownerKey=new NamespacedKey(plugin,"character_gear_owner");sessionKey=new NamespacedKey(plugin,"character_gear_session");slotKey=new NamespacedKey(plugin,"character_gear_slot");tierKey=new NamespacedKey(plugin,"character_gear_tier");}
|
||||
public ItemStack create(GearSlot slot,GearTier tier,UUID owner,UUID session){if(tier==GearTier.NONE)return null;var item=new ItemStack(material(slot,tier));var meta=item.getItemMeta();meta.setUnbreakable(true);meta.setMaxStackSize(1);meta.displayName(Component.text("Fantasy "+title(tier)+" "+title(slot)));var data=meta.getPersistentDataContainer();data.set(typeKey,PersistentDataType.INTEGER,1);data.set(ownerKey,PersistentDataType.STRING,owner.toString());data.set(sessionKey,PersistentDataType.STRING,session.toString());data.set(slotKey,PersistentDataType.STRING,slot.name());data.set(tierKey,PersistentDataType.STRING,tier.name());item.setItemMeta(meta);return item;}
|
||||
public boolean gear(ItemStack item){return item!=null&&!item.isEmpty()&&Integer.valueOf(1).equals(item.getItemMeta().getPersistentDataContainer().get(typeKey,PersistentDataType.INTEGER));}
|
||||
public boolean owned(ItemStack item,UUID owner,UUID session){if(!gear(item))return false;var data=item.getItemMeta().getPersistentDataContainer();return owner.toString().equals(data.get(ownerKey,PersistentDataType.STRING))&&session.toString().equals(data.get(sessionKey,PersistentDataType.STRING));}
|
||||
public void install(PlayerInventory inventory,CharacterRecord record,UUID owner,UUID session){inventory.setChestplate(create(GearSlot.CHESTPLATE,record.gear().chestplate(),owner,session));inventory.setLeggings(create(GearSlot.LEGGINGS,record.gear().leggings(),owner,session));inventory.setBoots(create(GearSlot.BOOTS,record.gear().boots(),owner,session));}
|
||||
private static String title(Enum<?> value){String text=value.name().toLowerCase(Locale.ROOT).replace('_',' ');return Character.toUpperCase(text.charAt(0))+text.substring(1);}
|
||||
private static Material material(GearSlot slot,GearTier tier){return Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";});}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public final class TailBooks {
|
||||
.append("\nCoins: ").append(character.coins());
|
||||
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
|
||||
pages.addAll(BookPagination.pages(summary.toString()));
|
||||
pages.addAll(BookPagination.pages("Current gear\nHead: Cosmetic only\nChestplate: "+tier(character.gear().chestplate())+"\nLeggings: "+tier(character.gear().leggings())+"\nBoots: "+tier(character.gear().boots())));
|
||||
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
|
||||
character.learned().stream().sorted().forEach(ability -> {
|
||||
var slots = new ArrayList<String>();
|
||||
@@ -74,6 +75,11 @@ public final class TailBooks {
|
||||
return item;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public Optional<Identity> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.WRITTEN_BOOK || !item.hasItemMeta()) return Optional.empty();
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
|
||||
@@ -33,7 +33,7 @@ public final class PartyRegistrarController implements Listener, AutoCloseable {
|
||||
private Location location(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());return new Location(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 chunk(double coordinate){return ((int)Math.floor(coordinate))>>4;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);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 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);}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorCatalog;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Durable adapter boundary for the one shared armor catalog. */
|
||||
public interface ArmorCatalogRepository {
|
||||
Optional<ArmorCatalog> load() throws IOException;
|
||||
void save(ArmorCatalog catalog) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorProduct;
|
||||
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 ArmorPurchaseOperationStore implements AutoCloseable {
|
||||
public record Plan(UUID id, UUID character, BigInteger expectedCharacterRevision, UUID catalogRevision,
|
||||
ArmorProduct 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 armor purchase plan");
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armor-operation-persistence"));
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
|
||||
public ArmorPurchaseOperationStore(Path data){directory=data.toAbsolutePath().resolve("armor-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("Armor 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("Armor 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("slot",plan.product().slot().name());
|
||||
row.put("tier",plan.product().tier().name());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 armor 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")),
|
||||
new ArmorProduct(GearSlot.valueOf(text(row,"slot")),GearTier.valueOf(text(row,"tier"))),new BigInteger(text(row,"price")));}
|
||||
private static void write(Path target,String content)throws IOException {Path temporary=Files.createTempFile(target.getParent(),"armor-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 armor-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("Armor 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,11 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Permanent helmet-free character armor state. */
|
||||
public record CharacterGear(GearTier chestplate,GearTier leggings,GearTier boots) {
|
||||
public CharacterGear{if(chestplate==null||leggings==null||boots==null)throw new IllegalArgumentException("Missing gear tier");}
|
||||
public static CharacterGear none(){return new CharacterGear(GearTier.NONE,GearTier.NONE,GearTier.NONE);}
|
||||
public GearTier tier(GearSlot slot){return switch(slot){case CHESTPLATE->chestplate;case LEGGINGS->leggings;case BOOTS->boots;};}
|
||||
public CharacterGear with(GearSlot slot,GearTier tier){Objects.requireNonNull(slot);Objects.requireNonNull(tier);return switch(slot){case CHESTPLATE->new CharacterGear(tier,leggings,boots);case LEGGINGS->new CharacterGear(chestplate,tier,boots);case BOOTS->new CharacterGear(chestplate,leggings,tier);};}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public record CharacterRecord(
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt,
|
||||
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow) {
|
||||
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,CharacterGear gear) {
|
||||
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,CharacterGear.none());}
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
@@ -63,6 +64,7 @@ public record CharacterRecord(
|
||||
Objects.requireNonNull(health);
|
||||
Objects.requireNonNull(coins);
|
||||
Objects.requireNonNull(pvpCoinEscrow);
|
||||
Objects.requireNonNull(gear);
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
|
||||
@@ -65,7 +65,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|
||||
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|
||||
|| !record.learned().isEmpty() || record.passives().stream().anyMatch(id -> !id.isEmpty())
|
||||
|| !record.cooldowns().isEmpty() || !record.coinsInitialized() || !record.rewardReceipts().isEmpty()
|
||||
|| !record.cooldowns().isEmpty() || !record.coinsInitialized() || !record.rewardReceipts().isEmpty() || !record.gear().equals(CharacterGear.none())
|
||||
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
|
||||
throw new IllegalArgumentException("Craft batch requires distinct new characters");
|
||||
}
|
||||
@@ -215,7 +215,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var migrated = 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(), old.dead(), old.deathId(), old.diedAt(),
|
||||
coinRules.get().starting(coinRandom), true, old.rewardReceipts());
|
||||
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(migrated, old.revision());
|
||||
return Optional.of(migrated);
|
||||
}
|
||||
@@ -231,7 +231,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
var renamed = new CharacterRecord(old.id(), 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(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
@@ -271,7 +271,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -307,7 +307,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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(amount), true, receipts);
|
||||
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CoinSpend(CoinSpendStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -316,7 +316,12 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
public enum CoinRefundStatus { APPLIED, DUPLICATE, UNAVAILABLE }
|
||||
public record CoinRefund(CoinRefundStatus status,Optional<CharacterRecord> record){}
|
||||
/** Compensate a persisted debit exactly once while retaining both audit receipts. */
|
||||
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);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(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
|
||||
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);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(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum GearPurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,NOT_UPGRADE,STALE,UNAVAILABLE }
|
||||
public record GearPurchase(GearPurchaseStatus status,Optional<CharacterRecord> record){}
|
||||
/** 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));repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.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) { }
|
||||
@@ -371,7 +376,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
|
||||
old.coins().add(coinReward), true, receipts);
|
||||
old.coins().add(coinReward), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -411,7 +416,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -439,7 +444,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -474,7 +479,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -494,7 +499,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -517,7 +522,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
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(), loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -541,7 +546,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
|
||||
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(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -586,7 +591,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -612,7 +617,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -649,7 +654,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt,
|
||||
loseCoins ? BigInteger.ZERO : old.coins(), old.coinsInitialized(), old.rewardReceipts(),
|
||||
loseCoins ? old.coins() : BigInteger.ZERO);
|
||||
loseCoins ? old.coins() : BigInteger.ZERO,old.gear());
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
@@ -686,7 +691,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ final class CharacterYaml {
|
||||
root.put("coins", record.coins().toString());
|
||||
root.put("reward-receipts", new ArrayList<>(record.rewardReceipts().stream().sorted().map(UUID::toString).toList()));
|
||||
root.put("pvp-coin-escrow", record.pvpCoinEscrow().toString());
|
||||
root.put("gear",Map.of("chestplate",record.gear().chestplate().name(),"leggings",record.gear().leggings().name(),"boots",record.gear().boots().name()));
|
||||
root.put("dead", record.dead());
|
||||
if (record.deathId() != null) {
|
||||
root.put("death-id", record.deathId().toString());
|
||||
@@ -106,9 +107,12 @@ final class CharacterYaml {
|
||||
root.containsKey("coins-initialized") && bool(root.get("coins-initialized")),
|
||||
root.containsKey("reward-receipts") ? strings(root.get("reward-receipts")).stream()
|
||||
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet()) : java.util.Set.of(),
|
||||
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO);
|
||||
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO,
|
||||
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none());
|
||||
}
|
||||
|
||||
private static CharacterGear gear(Object value){var gear=object(value);return new CharacterGear(GearTier.valueOf(text(gear.get("chestplate"))),GearTier.valueOf(text(gear.get("leggings"))),GearTier.valueOf(text(gear.get("boots"))));}
|
||||
|
||||
private static Map<?, ?> object(Object value) {
|
||||
if (value instanceof Map<?, ?> map) return map;
|
||||
throw new IllegalArgumentException("Expected YAML mapping");
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorMarketRules;
|
||||
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;
|
||||
|
||||
/** Durable save-before-publication bases, variance and tier stock bounds for future catalogs. */
|
||||
public final class GearSettings implements AutoCloseable {
|
||||
public static final String LEATHER="gear-price-leather", COPPER="gear-price-copper",
|
||||
GOLD="gear-price-gold", IRON="gear-price-iron", DIAMOND="gear-price-diamond",
|
||||
NETHERITE="gear-price-netherite", VARIANCE="gear-price-variance-percent";
|
||||
private static final Map<GearTier,String> PRICE_KEYS=Map.of(
|
||||
GearTier.LEATHER,LEATHER, GearTier.COPPER,COPPER, GearTier.GOLD,GOLD,
|
||||
GearTier.IRON,IRON, GearTier.DIAMOND,DIAMOND, GearTier.NETHERITE,NETHERITE);
|
||||
private static final Map<GearTier,String> TIER_NAMES=Map.of(
|
||||
GearTier.LEATHER,"leather", GearTier.COPPER,"copper", GearTier.GOLD,"gold",
|
||||
GearTier.IRON,"iron", GearTier.DIAMOND,"diamond", GearTier.NETHERITE,"netherite");
|
||||
private static final Map<String,String> DEFAULTS=defaults();
|
||||
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
|
||||
|
||||
public record Update(String key,String value,Map<String,String> values,long sequence) {
|
||||
public Update { values=Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-gear-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 GearSettings(Path directory,Map<String,String> initial) {
|
||||
this.directory=Objects.requireNonNull(directory).toAbsolutePath();
|
||||
var values=new LinkedHashMap<String,String>();
|
||||
for(String 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 int price(GearTier tier){return wholeNonnegative(effective.get(priceKey(tier)));}
|
||||
|
||||
public ArmorMarketRules rules() {
|
||||
var values=effective;
|
||||
var prices=new EnumMap<GearTier,BigInteger>(GearTier.class);
|
||||
var stock=new EnumMap<GearTier,ArmorMarketRules.StockRange>(GearTier.class);
|
||||
for(var tier:PRICE_KEYS.keySet()) {
|
||||
prices.put(tier,new BigInteger(values.get(priceKey(tier))));
|
||||
stock.put(tier,new ArmorMarketRules.StockRange(
|
||||
wholePositive(values.get(stockKey(tier,true))), wholePositive(values.get(stockKey(tier,false)))));
|
||||
}
|
||||
return new ArmorMarketRules(prices,wholeVariance(values.get(VARIANCE)),stock);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key,String raw) {
|
||||
if(!KEYS.contains(key)) throw new IllegalArgumentException("Unknown gear market setting");
|
||||
String accepted;
|
||||
try { accepted=Integer.toString(parseForKey(key,raw)); }
|
||||
catch(RuntimeException invalid) { throw new IllegalArgumentException("Invalid gear market setting",invalid); }
|
||||
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;
|
||||
}
|
||||
|
||||
private static Map<String,String> defaults() {
|
||||
var values=new LinkedHashMap<String,String>();
|
||||
values.put(LEATHER,"2"); values.put(COPPER,"10"); values.put(GOLD,"25");
|
||||
values.put(IRON,"75"); values.put(DIAMOND,"150"); values.put(NETHERITE,"500");
|
||||
values.put(VARIANCE,"10");
|
||||
int[][] ranges={{25,50},{20,40},{15,30},{10,20},{5,10},{1,3}};
|
||||
GearTier[] tiers={GearTier.LEATHER,GearTier.COPPER,GearTier.GOLD,GearTier.IRON,GearTier.DIAMOND,GearTier.NETHERITE};
|
||||
for(int i=0;i<tiers.length;i++) {
|
||||
values.put(stockKey(tiers[i],true),Integer.toString(ranges[i][0]));
|
||||
values.put(stockKey(tiers[i],false),Integer.toString(ranges[i][1]));
|
||||
}
|
||||
return Map.copyOf(values);
|
||||
}
|
||||
|
||||
private static void validate(Map<String,String> values) {
|
||||
for(var tier:PRICE_KEYS.keySet()) {
|
||||
wholeNonnegative(values.get(priceKey(tier)));
|
||||
int minimum=wholePositive(values.get(stockKey(tier,true)));
|
||||
int maximum=wholePositive(values.get(stockKey(tier,false)));
|
||||
if(minimum>maximum)throw new IllegalArgumentException("Stock minimum exceeds maximum");
|
||||
}
|
||||
wholeVariance(values.get(VARIANCE));
|
||||
}
|
||||
|
||||
private static int parseForKey(String key,String value) {
|
||||
if(key.equals(VARIANCE))return wholeVariance(value);
|
||||
if(key.startsWith("gear-stock-"))return wholePositive(value);
|
||||
return wholeNonnegative(value);
|
||||
}
|
||||
private static int wholeNonnegative(String value){return bounded(value,0,Integer.MAX_VALUE);}
|
||||
private static int wholePositive(String value){return bounded(value,1,Integer.MAX_VALUE);}
|
||||
private static int wholeVariance(String value){return bounded(value,0,100);}
|
||||
private static int bounded(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 static String priceKey(GearTier tier) {
|
||||
var key=PRICE_KEYS.get(tier);
|
||||
if(key==null)throw new IllegalArgumentException("None is not purchasable");
|
||||
return key;
|
||||
}
|
||||
public static String stockKey(GearTier tier,boolean minimum) {
|
||||
var name=TIER_NAMES.get(tier);
|
||||
if(name==null)throw new IllegalArgumentException("None is not purchasable");
|
||||
return "gear-stock-"+name+(minimum?"-min":"-max");
|
||||
}
|
||||
|
||||
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-",".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,4 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
/** Purchasable character armor slots; head is intentionally absent and remains cosmetic-only. */
|
||||
public enum GearSlot { CHESTPLATE, LEGGINGS, BOOTS }
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
/** Strict reviewed armor progression; enum order is authoritative for upgrade comparisons. */
|
||||
public enum GearTier {
|
||||
NONE, LEATHER, COPPER, GOLD, IRON, DIAMOND, NETHERITE;
|
||||
public boolean higherThan(GearTier current){return ordinal()>current.ordinal();}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorCatalog;
|
||||
import games.dmg.fantasystick.shop.ArmorProduct;
|
||||
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 armor catalog. */
|
||||
public final class YamlArmorCatalogRepository implements ArmorCatalogRepository {
|
||||
private final Path file;
|
||||
public YamlArmorCatalogRepository(Path data){file=data.toAbsolutePath().resolve("armor-catalog.yml");}
|
||||
|
||||
@Override public Optional<ArmorCatalog> 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<ArmorProduct,ArmorCatalog.Offer>();
|
||||
for(Object value:list(root.get("products"))) {
|
||||
var row=map(value);
|
||||
var product=parseProduct(text(row,"product"));
|
||||
if(products.put(product,new ArmorCatalog.Offer(new BigInteger(text(row,"price")),
|
||||
Integer.parseInt(text(row,"remaining"))))!=null)throw new IllegalArgumentException("Duplicate product");
|
||||
}
|
||||
var transactions=new LinkedHashMap<UUID,ArmorCatalog.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 ArmorCatalog.Transaction(parseProduct(text(row,"product")),
|
||||
ArmorCatalog.TransactionState.valueOf(text(row,"state")));
|
||||
if(transactions.put(id,transaction)!=null)throw new IllegalArgumentException("Duplicate operation");
|
||||
}
|
||||
return Optional.of(new ArmorCatalog(LocalDate.parse(text(root,"catalog-day")),
|
||||
UUID.fromString(text(root,"revision")),Instant.parse(text(root,"refreshed-at")),
|
||||
ArmorCatalog.RefreshReason.valueOf(text(root,"refresh-reason")),products,transactions));
|
||||
} catch(RuntimeException invalid) { throw new IOException("Invalid armor catalog",invalid); }
|
||||
}
|
||||
|
||||
@Override public void save(ArmorCatalog 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((ArmorProduct value)->value.slot().name()).thenComparing(value->value.tier().name())))
|
||||
.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(),"armor-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 armor catalog persistence unavailable",unsupported);}
|
||||
DurableFiles.forceDirectory(file.getParent());
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private static String product(ArmorProduct product){return product.slot().name()+"-"+product.tier().name();}
|
||||
private static ArmorProduct parseProduct(String value){int split=value.indexOf('-');if(split<1)throw new IllegalArgumentException();return new ArmorProduct(
|
||||
GearSlot.valueOf(value.substring(0,split)),GearTier.valueOf(value.substring(split+1)));}
|
||||
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,9 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import games.dmg.fantasystick.shop.FantasyArmorer;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 Armorer registrations. */
|
||||
public final class YamlFantasyArmorerRepository{
|
||||
private final Path file;public YamlFantasyArmorerRepository(Path data){file=data.toAbsolutePath().resolve("fantasy-armorers.yml");}
|
||||
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
|
||||
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static void validate(List<FantasyArmorer> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Armorer identity");}private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException();}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
@@ -79,6 +80,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
|
||||
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
|
||||
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -306,6 +310,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
|
||||
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
|
||||
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
|
||||
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
|
||||
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
|
||||
@@ -360,12 +365,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
partyRegistrars=Objects.requireNonNull(directory); partyRegistrarController=Objects.requireNonNull(controller);
|
||||
}
|
||||
|
||||
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);}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
|
||||
if (args.length > 0 && args[0].equals("party-registrar")) {
|
||||
partyRegistrar(sender,args); return true;
|
||||
}
|
||||
@@ -493,6 +502,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
|
||||
private void shop(CommandSender sender,String[] args){
|
||||
if(armorers==null||armorerController==null||args.length<2){shopUsage(sender);return;}
|
||||
if(args[1].equals("restock")&&args.length==2&&armorMarket!=null){armorMarket.restock().whenComplete((catalog,failure)->{
|
||||
if(failure!=null)report(null,"shop-restock",failure);if(closed)return;try{nextTick.accept(()->{
|
||||
if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
if(failure!=null)sender.sendMessage("Fantasy Armorer restock failed; the prior catalog is unchanged.");
|
||||
else sender.sendMessage("Restocked all Fantasy Armorers with catalog "+catalog.revision()+"; the next automatic restock remains 00:00 UTC.");
|
||||
});}catch(RuntimeException rejected){report(null,"shop-restock-response",rejected);}});return;}
|
||||
if(args[1].equals("list")&&args.length==2){var values=armorers.all();if(values.isEmpty())sender.sendMessage("No Fantasy Armorers are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Fantasy Armorer":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
|
||||
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,saved,failure));return;}
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
|
||||
shopUsage(sender);
|
||||
}
|
||||
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fantasystick shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
|
||||
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
|
||||
|
||||
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
|
||||
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
|
||||
private void partyRegistrar(CommandSender sender,String[] args){
|
||||
@@ -534,6 +560,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
|
||||
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 (coinSettings != null && games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])) { coinConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
|
||||
@@ -1129,6 +1156,8 @@ 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 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);}});}
|
||||
|
||||
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
@@ -1175,7 +1204,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "revive") : List.of("config", "experience", "give", "inspect", "party-registrar", "recover"), args[0]);
|
||||
? List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "revive","shop") : List.of("config", "experience", "give", "inspect", "party-registrar", "recover","shop"), args[0]);
|
||||
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("party-registrar")) 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("party-registrar") && args[1].equals("remove") && partyRegistrars!=null)
|
||||
@@ -1190,6 +1221,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args.length == 2 && args[0].equals("config")) {
|
||||
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.CoinSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
|
||||
@@ -1221,6 +1253,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
|
||||
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(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(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
|
||||
if (outOfCombatRecoverySettings != null
|
||||
|
||||
@@ -4,6 +4,7 @@ import games.dmg.fantasystick.crafting.ManualDropDelivery;
|
||||
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.items.GearItems;
|
||||
import games.dmg.fantasystick.items.SessionControls;
|
||||
import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent;
|
||||
import java.util.Arrays;
|
||||
@@ -41,6 +42,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
private final Consumer<EquipmentSessions.Failure> diagnostics;
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private SessionControls controls = new SessionControls();
|
||||
private GearItems gear;
|
||||
private static final class Intent { boolean started; }
|
||||
private final Map<UUID, Intent> swaps = new HashMap<>();
|
||||
private final Map<UUID, UUID> observed = new HashMap<>();
|
||||
@@ -71,6 +73,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
}
|
||||
private boolean closed;
|
||||
private Consumer<ControlInput> inputs = ignored -> { };
|
||||
private java.util.function.Predicate<org.bukkit.entity.Entity> managedInteractionTarget = ignored -> false;
|
||||
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
|
||||
private final Map<UUID, Draw> draws = new HashMap<>();
|
||||
|
||||
@@ -94,6 +97,13 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
controls = new SessionControls(Objects.requireNonNull(registry));
|
||||
}
|
||||
|
||||
public void attachGear(GearItems gear){if(closed||this.gear!=null)throw new IllegalStateException("Gear already attached or events closed");this.gear=Objects.requireNonNull(gear);}
|
||||
|
||||
public void attachManagedInteractionTarget(java.util.function.Predicate<org.bukkit.entity.Entity> target) {
|
||||
if (closed) throw new IllegalStateException("Events closed");
|
||||
managedInteractionTarget = Objects.requireNonNull(target);
|
||||
}
|
||||
|
||||
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
|
||||
@@ -436,6 +446,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void useEntity(org.bukkit.event.player.PlayerInteractEntityEvent event) {
|
||||
if (managedInteractionTarget.test(event.getRightClicked())) return;
|
||||
var player = event.getPlayer();
|
||||
var item = player.getInventory().getItem(event.getHand());
|
||||
if (special(item)) event.setCancelled(true);
|
||||
@@ -570,7 +581,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
private boolean active(Player player) { return sessions.active(player.getUniqueId()).isPresent(); }
|
||||
private boolean control(ItemStack item) { return pickupClaim(item, "control_move_") || controls.identify(item).isPresent(); }
|
||||
private boolean special(ItemStack item) { return control(item) || box(item) != null; }
|
||||
private boolean special(ItemStack item) { return control(item) || box(item) != null || gear != null && gear.gear(item); }
|
||||
private static boolean ownInventory(Player player, Inventory inventory) {
|
||||
return inventory instanceof PlayerInventory && inventory.equals(player.getInventory());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.items.GearItems;
|
||||
import games.dmg.fantasystick.items.SessionControls;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
@@ -53,6 +54,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private final SessionClaims claims = new SessionClaims();
|
||||
private final EquipmentStorage equipment = new EquipmentStorage();
|
||||
private SessionControls controls;
|
||||
private GearItems gear;
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
@@ -155,6 +157,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
/** Install before exposing entry so later checks/exits capture the acknowledged live policy. */
|
||||
public synchronized void attachGear(GearItems gear){if(closed||this.gear!=null)throw new IllegalStateException("Gear already attached or sessions closed");this.gear=Objects.requireNonNull(gear);}
|
||||
|
||||
public synchronized void attachSwitchDelay(Supplier<Duration> policy) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
switchDelay = Objects.requireNonNull(policy);
|
||||
@@ -282,6 +286,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var previous = attempt.record;
|
||||
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
|
||||
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
|
||||
if(gear!=null&&!previous.gear().equals(saved.gear()))gear.install(attempt.player.getInventory(),saved,attempt.owner,attempt.session);
|
||||
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
|
||||
&& !refreshControls(attempt, session)) return false;
|
||||
return true;
|
||||
@@ -426,7 +431,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
// Even malformed/foreign remnants are unsafe to pack as ordinary equipment.
|
||||
return item.getItemMeta().getPersistentDataContainer().getKeys().stream().anyMatch(key ->
|
||||
key.getNamespace().equals("fantasystick")
|
||||
&& (key.getKey().startsWith("storage_") || key.getKey().startsWith("control_")));
|
||||
&& (key.getKey().startsWith("storage_") || key.getKey().startsWith("control_") || key.getKey().startsWith("character_gear")));
|
||||
}
|
||||
|
||||
private synchronized void loaded(Attempt attempt, Loaded loaded, Throwable failure) {
|
||||
@@ -521,6 +526,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.player.getInventory().setItem(slot, items[slot]);
|
||||
if (interrupted(attempt)) return;
|
||||
}
|
||||
if(gear!=null)gear.install(attempt.player.getInventory(),record,attempt.owner,attempt.session);
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
|
||||
if (vitals != null) {
|
||||
attempt.vitalsStarted = true;
|
||||
@@ -792,7 +798,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
mark(attempt, EquipmentSessionMarker.Phase.RESTORING);
|
||||
var result = equipment.restore(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
item -> controls.owned(item, attempt.owner, attempt.session),
|
||||
item -> controls.owned(item, attempt.owner, attempt.session)||(gear!=null&&gear.owned(item,attempt.owner,attempt.session)),
|
||||
attempt.player.getItemOnCursor(), attempt.player::setItemOnCursor);
|
||||
// Returned refusals are read-only. Exceptions must retain the ambiguous RESTORING phase.
|
||||
mark(attempt, result == EquipmentStorage.RestoreResult.RESTORED
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.GearSlot;
|
||||
import games.dmg.fantasystick.persistence.GearTier;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
/** One acknowledged server-wide generation shared by every Fantasy Armorer. */
|
||||
public record ArmorCatalog(LocalDate day, UUID revision, Instant refreshedAt, RefreshReason reason,
|
||||
Map<ArmorProduct, 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 catalog offer");
|
||||
}
|
||||
}
|
||||
public record Transaction(ArmorProduct product, TransactionState state) {
|
||||
public Transaction { Objects.requireNonNull(product); Objects.requireNonNull(state); }
|
||||
}
|
||||
|
||||
public ArmorCatalog(LocalDate day, UUID revision, Instant refreshedAt, RefreshReason reason,
|
||||
Map<ArmorProduct, Offer> products) {
|
||||
this(day, revision, refreshedAt, reason, products, Map.of());
|
||||
}
|
||||
|
||||
public ArmorCatalog {
|
||||
Objects.requireNonNull(day);
|
||||
Objects.requireNonNull(revision);
|
||||
Objects.requireNonNull(refreshedAt);
|
||||
Objects.requireNonNull(reason);
|
||||
products = Map.copyOf(products);
|
||||
transactions = Map.copyOf(transactions);
|
||||
var expected = new HashSet<ArmorProduct>();
|
||||
for (var slot : GearSlot.values()) for (var tier : GearTier.values())
|
||||
if (tier != GearTier.NONE) expected.add(new ArmorProduct(slot,tier));
|
||||
if (!products.keySet().equals(expected)) throw new IllegalArgumentException("Catalog must contain all 18 products");
|
||||
}
|
||||
|
||||
public Offer offer(ArmorProduct product) {
|
||||
var offer = products.get(Objects.requireNonNull(product));
|
||||
if (offer == null) throw new IllegalArgumentException("Product is absent from catalog");
|
||||
return offer;
|
||||
}
|
||||
|
||||
public ArmorCatalog reserve(UUID operation, ArmorProduct product) {
|
||||
Objects.requireNonNull(operation);
|
||||
var existing=transactions.get(operation);
|
||||
if(existing!=null) {
|
||||
if(!existing.product().equals(product))throw new IllegalArgumentException("Operation identity collision");
|
||||
return this;
|
||||
}
|
||||
var offer=offer(product);
|
||||
if(offer.remaining()==0)throw new IllegalStateException("Product is 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 ArmorCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);
|
||||
}
|
||||
|
||||
public ArmorCatalog settle(UUID operation, boolean consumed) {
|
||||
var transaction=transactions.get(Objects.requireNonNull(operation));
|
||||
if(transaction==null)return this;
|
||||
if(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 ArmorCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);
|
||||
}
|
||||
|
||||
public ArmorCatalog prune(UUID operation) {
|
||||
if(!transactions.containsKey(operation))return this;
|
||||
var next=new LinkedHashMap<>(transactions); next.remove(operation);
|
||||
return new ArmorCatalog(day,revision,refreshedAt,reason,products,next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.GearSlot;
|
||||
import games.dmg.fantasystick.persistence.GearTier;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Exact integer catalog generation without floating-point price drift. */
|
||||
public final class ArmorCatalogGenerator {
|
||||
@FunctionalInterface public interface InclusiveRoll { int roll(int minimum, int maximum); }
|
||||
private static final BigInteger BASIS_POINTS = BigInteger.valueOf(10_000);
|
||||
|
||||
private ArmorCatalogGenerator() { }
|
||||
|
||||
public static ArmorCatalog generate(LocalDate day, Instant refreshedAt, ArmorCatalog.RefreshReason reason,
|
||||
ArmorMarketRules rules, InclusiveRoll random) {
|
||||
Objects.requireNonNull(rules);
|
||||
Objects.requireNonNull(random);
|
||||
var products = new LinkedHashMap<ArmorProduct, ArmorCatalog.Offer>();
|
||||
for (var slot : GearSlot.values()) {
|
||||
for (var tier : GearTier.values()) {
|
||||
if (tier == GearTier.NONE) continue;
|
||||
int spread = Math.multiplyExact(100, rules.variancePercent());
|
||||
int offset = checked(random, -spread, spread);
|
||||
var numerator = rules.basePrices().get(tier).multiply(BigInteger.valueOf(10_000L + offset));
|
||||
var division = numerator.divideAndRemainder(BASIS_POINTS);
|
||||
var price = division[0].add(division[1].signum() == 0 ? BigInteger.ZERO : BigInteger.ONE).max(BigInteger.ONE);
|
||||
var range = rules.stock().get(tier);
|
||||
int remaining = checked(random, range.minimum(), range.maximum());
|
||||
products.put(new ArmorProduct(slot, tier), new ArmorCatalog.Offer(price, remaining));
|
||||
}
|
||||
}
|
||||
return new ArmorCatalog(day, UUID.randomUUID(), refreshedAt, reason, products);
|
||||
}
|
||||
|
||||
private static int checked(InclusiveRoll random, int minimum, int maximum) {
|
||||
int result = random.roll(minimum, maximum);
|
||||
if (result < minimum || result > maximum) throw new IllegalArgumentException("Random roll outside requested range");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import java.io.IOException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Serialized durable authority for the one shared daily armor catalog. */
|
||||
public final class ArmorMarket implements AutoCloseable {
|
||||
public enum PurchaseStatus { APPLIED, SOLD_OUT, STALE, INSUFFICIENT, NOT_UPGRADE, UNAVAILABLE, FAILED }
|
||||
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
|
||||
public PurchaseOutcome { Objects.requireNonNull(status); Objects.requireNonNull(record); Objects.requireNonNull(catalog); }
|
||||
}
|
||||
|
||||
private final ArmorCatalogRepository catalogs;
|
||||
private final ArmorPurchaseOperationStore operations;
|
||||
private final CharacterStorage characters;
|
||||
private final Supplier<ArmorMarketRules> rules;
|
||||
private final Clock clock;
|
||||
private final ArmorCatalogGenerator.InclusiveRoll random;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armor-market"));
|
||||
private final CompletionStage<Void> readiness;
|
||||
private volatile ArmorCatalog snapshot;
|
||||
private volatile boolean initialized;
|
||||
private volatile Consumer<ArmorCatalog> listener=ignored->{};
|
||||
private boolean closed,operationsClosing;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
|
||||
public ArmorMarket(ArmorCatalogRepository catalogs, ArmorPurchaseOperationStore operations,
|
||||
CharacterStorage characters, Supplier<ArmorMarketRules> rules, Clock clock,
|
||||
ArmorCatalogGenerator.InclusiveRoll random) {
|
||||
this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);
|
||||
this.characters=Objects.requireNonNull(characters);this.rules=Objects.requireNonNull(rules);
|
||||
this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);
|
||||
readiness=submit(()->{initialize();return null;});
|
||||
}
|
||||
|
||||
public void attachListener(Consumer<ArmorCatalog> listener){this.listener=Objects.requireNonNull(listener);}
|
||||
public Optional<ArmorCatalog> snapshot(){return Optional.ofNullable(snapshot);}
|
||||
public Instant now(){return clock.instant();}
|
||||
public CompletionStage<Void> ready(){return readiness;}
|
||||
public CompletionStage<ArmorCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(ArmorCatalog.RefreshReason.SCHEDULED)));}
|
||||
public CompletionStage<ArmorCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{
|
||||
var current=ensureCurrent(ArmorCatalog.RefreshReason.SCHEDULED);
|
||||
if(!current.transactions().isEmpty())throw new IOException("Armor purchases are unresolved");
|
||||
var generated=ArmorCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),
|
||||
clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
|
||||
ArmorCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);
|
||||
persist(generated,true);return generated;
|
||||
}));}
|
||||
public static Instant nextScheduledRestock(Instant now){return Objects.requireNonNull(now).atZone(ZoneOffset.UTC)
|
||||
.toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
|
||||
public CompletionStage<PurchaseOutcome> purchase(UUID character, java.math.BigInteger expectedCharacterRevision,
|
||||
UUID expectedCatalogRevision, ArmorProduct product) {
|
||||
Objects.requireNonNull(character);Objects.requireNonNull(expectedCharacterRevision);
|
||||
Objects.requireNonNull(expectedCatalogRevision);Objects.requireNonNull(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("Armor catalog is 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()==ArmorCatalog.TransactionState.RESERVED)
|
||||
throw new IOException("Orphan reserved armor purchase "+entry.getKey());
|
||||
next=next.prune(entry.getKey());
|
||||
}
|
||||
persist(next,false);
|
||||
}
|
||||
ensureCurrent(ArmorCatalog.RefreshReason.STARTUP);
|
||||
initialized=true;
|
||||
}
|
||||
|
||||
private PurchaseOutcome purchaseBlocking(UUID character,java.math.BigInteger expectedCharacterRevision,
|
||||
UUID expectedCatalogRevision,ArmorProduct product)throws Exception {
|
||||
var catalog=ensureCurrent(ArmorCatalog.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);
|
||||
var offer=catalog.offer(product);
|
||||
if(offer.remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);
|
||||
var plan=new ArmorPurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,
|
||||
catalog.revision(),product,offer.price());
|
||||
operations.prepare(plan).toCompletableFuture().join();
|
||||
return execute(plan);
|
||||
}
|
||||
|
||||
private PurchaseOutcome execute(ArmorPurchaseOperationStore.Plan plan)throws Exception {
|
||||
var catalog=Objects.requireNonNull(snapshot,"Armor catalog unavailable");
|
||||
if(!catalog.revision().equals(plan.catalogRevision()))
|
||||
throw new IOException("Pending armor purchase belongs to another catalog revision");
|
||||
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().equals(plan.product()))throw new IOException("Armor reservation product mismatch");
|
||||
if(transaction.state()!=ArmorCatalog.TransactionState.RESERVED) {
|
||||
operations.complete(plan.id()).toCompletableFuture().join();
|
||||
var pruned=catalog.prune(plan.id());persist(pruned,false);
|
||||
var record=characters.find(plan.character()).join();
|
||||
return new PurchaseOutcome(transaction.state()==ArmorCatalog.TransactionState.CONSUMED
|
||||
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned);
|
||||
}
|
||||
CharacterStorage.GearPurchase purchase;
|
||||
try {
|
||||
purchase=characters.purchaseGear(plan.character(),plan.expectedCharacterRevision(),plan.id(),
|
||||
plan.product().slot(),plan.product().tier(),plan.price()).join();
|
||||
} catch(CompletionException failure) { throw failure; }
|
||||
boolean consumed=purchase.status()==CharacterStorage.GearPurchaseStatus.APPLIED||
|
||||
purchase.status()==CharacterStorage.GearPurchaseStatus.DUPLICATE;
|
||||
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 NOT_UPGRADE->PurchaseStatus.NOT_UPGRADE;
|
||||
case STALE->PurchaseStatus.STALE;
|
||||
case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;
|
||||
};
|
||||
return new PurchaseOutcome(status,purchase.record(),catalog);
|
||||
}
|
||||
|
||||
private ArmorCatalog ensureCurrent(ArmorCatalog.RefreshReason reason)throws IOException {
|
||||
LocalDate today=LocalDate.now(clock.withZone(ZoneOffset.UTC));
|
||||
var current=snapshot;
|
||||
if(current!=null&¤t.day().equals(today))return current;
|
||||
if(current!=null&&!current.transactions().isEmpty())throw new IOException("Armor purchases are unresolved at catalog rollover");
|
||||
var generated=ArmorCatalogGenerator.generate(today,
|
||||
clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);
|
||||
persist(generated,true);
|
||||
return generated;
|
||||
}
|
||||
|
||||
private void persist(ArmorCatalog catalog,boolean notify)throws IOException {
|
||||
catalogs.save(catalog);snapshot=catalog;if(notify)publish(catalog);
|
||||
}
|
||||
|
||||
private void publish(ArmorCatalog catalog){if(!initialized)return;try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(
|
||||
new IOException("Armor 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,34 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.GearTier;
|
||||
import java.math.BigInteger;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Validated configuration captured for one future catalog generation. */
|
||||
public record ArmorMarketRules(Map<GearTier, BigInteger> basePrices, int variancePercent,
|
||||
Map<GearTier, StockRange> stock) {
|
||||
public record StockRange(int minimum, int maximum) {
|
||||
public StockRange {
|
||||
if (minimum <= 0 || maximum < minimum) throw new IllegalArgumentException("Invalid stock range");
|
||||
}
|
||||
}
|
||||
|
||||
public ArmorMarketRules {
|
||||
Objects.requireNonNull(basePrices);
|
||||
Objects.requireNonNull(stock);
|
||||
if (variancePercent < 0 || variancePercent > 100) throw new IllegalArgumentException("Invalid variance");
|
||||
var prices = new EnumMap<GearTier, BigInteger>(GearTier.class);
|
||||
var ranges = new EnumMap<GearTier, StockRange>(GearTier.class);
|
||||
for (var tier : GearTier.values()) {
|
||||
if (tier == GearTier.NONE) continue;
|
||||
var price = Objects.requireNonNull(basePrices.get(tier), "Missing base price");
|
||||
if (price.signum() < 0) throw new IllegalArgumentException("Negative base price");
|
||||
prices.put(tier, price);
|
||||
ranges.put(tier, Objects.requireNonNull(stock.get(tier), "Missing stock range"));
|
||||
}
|
||||
basePrices = Map.copyOf(prices);
|
||||
stock = Map.copyOf(ranges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.GearSlot;
|
||||
import games.dmg.fantasystick.persistence.GearTier;
|
||||
import java.util.Objects;
|
||||
|
||||
/** One independently priced and stocked armor slot/tier product. */
|
||||
public record ArmorProduct(GearSlot slot, GearTier tier) {
|
||||
public ArmorProduct {
|
||||
Objects.requireNonNull(slot);
|
||||
Objects.requireNonNull(tier);
|
||||
if (tier == GearTier.NONE) throw new IllegalArgumentException("None is not purchasable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Active-character Armorer boundary backed by the one recoverable shared market. */
|
||||
public final class ArmorShop {
|
||||
public enum Status { APPLIED, INVALID_SESSION, SOLD_OUT, INSUFFICIENT, NOT_UPGRADE, STALE, FAILED }
|
||||
public record Outcome(Status status,Optional<CharacterRecord> record){}
|
||||
public record View(CharacterRecord record,UUID session,ArmorCatalog catalog){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record,FantasyArmorer shop){}
|
||||
|
||||
private final FantasyArmorerDirectory shops;
|
||||
private final EquipmentSessions sessions;
|
||||
private final ArmorMarket market;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
|
||||
public ArmorShop(FantasyArmorerDirectory shops,EquipmentSessions sessions,ArmorMarket 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,ArmorProduct 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;
|
||||
}
|
||||
Status status=switch(purchase.status()){
|
||||
case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;
|
||||
case NOT_UPGRADE->Status.NOT_UPGRADE;case STALE->Status.STALE;
|
||||
case UNAVAILABLE,FAILED->Status.FAILED;
|
||||
};
|
||||
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())
|
||||
sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());
|
||||
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(FantasyArmorerDirectory.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,115 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.inventory.*;
|
||||
import org.bukkit.inventory.*;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Bounded shared-catalog armor shop; every click revalidates generation, character, session and shop. */
|
||||
public final class ArmorShopMenus implements Listener,AutoCloseable {
|
||||
private record Holder(UUID shop,UUID character,UUID session,UUID catalog)implements InventoryHolder{
|
||||
public Inventory getInventory(){return null;}}
|
||||
private final ArmorShop service;
|
||||
private final org.bukkit.Server server;
|
||||
private final NamespacedKey slotKey,tierKey;
|
||||
private boolean closed;
|
||||
|
||||
public ArmorShopMenus(Plugin plugin,ArmorShop service){this.service=Objects.requireNonNull(service);server=plugin.getServer();
|
||||
slotKey=new NamespacedKey(plugin,"armor_shop_slot");tierKey=new NamespacedKey(plugin,"armor_shop_tier");}
|
||||
|
||||
public void open(Player player,UUID shop){
|
||||
service.view(player,shop,view->{
|
||||
if(closed||!player.isOnline())return;
|
||||
if(view.isEmpty()){
|
||||
player.sendMessage("You must be actively playing a living fantasy character beside this Fantasy Armorer; the market must also be available.");return;
|
||||
}
|
||||
var current=view.orElseThrow();
|
||||
var inventory=server.createInventory(new Holder(shop,current.record().id(),current.session(),current.catalog().revision()),
|
||||
54,net.kyori.adventure.text.Component.text("Fantasy Armorer"));
|
||||
int column=0;
|
||||
for(var slot:GearSlot.values()){
|
||||
int row=0;
|
||||
for(var tier:GearTier.values()){
|
||||
if(tier==GearTier.NONE)continue;
|
||||
var product=new ArmorProduct(slot,tier);
|
||||
inventory.setItem(row*9+column,item(product,current.catalog().offer(product)));
|
||||
row++;
|
||||
}
|
||||
column+=3;
|
||||
}
|
||||
inventory.setItem(8,schedule(current.catalog(),service.now()));
|
||||
player.openInventory(inventory);
|
||||
});
|
||||
}
|
||||
|
||||
private ItemStack item(ArmorProduct product,ArmorCatalog.Offer offer){
|
||||
var tier=product.tier();var slot=product.slot();
|
||||
var item=new ItemStack(Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){
|
||||
case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";}));
|
||||
var meta=item.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Buy "+title(tier.name())+" "+title(slot.name())+
|
||||
" — "+offer.price()+" coins"));
|
||||
meta.lore(List.of(net.kyori.adventure.text.Component.text("Shared remaining: "+offer.remaining())));
|
||||
meta.getPersistentDataContainer().set(slotKey,PersistentDataType.STRING,slot.name());
|
||||
meta.getPersistentDataContainer().set(tierKey,PersistentDataType.STRING,tier.name());
|
||||
item.setItemMeta(meta);return item;
|
||||
}
|
||||
|
||||
private static ItemStack schedule(ArmorCatalog catalog,Instant now){
|
||||
var next=catalog.day().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
long seconds=Math.max(0,Duration.between(now,next).toSeconds());
|
||||
long 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("Daily at 00:00 UTC"));
|
||||
meta.lore(List.of(net.kyori.adventure.text.Component.text("Next: "+next),
|
||||
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(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("Armor purchase refused: the character, session or shop changed.");return;
|
||||
}
|
||||
var item=event.getCurrentItem();if(item==null)return;
|
||||
var data=item.getItemMeta().getPersistentDataContainer();
|
||||
try{
|
||||
var product=new ArmorProduct(GearSlot.valueOf(data.get(slotKey,PersistentDataType.STRING)),
|
||||
GearTier.valueOf(data.get(tierKey,PersistentDataType.STRING)));
|
||||
service.buy(player,holder.shop(),holder.catalog(),product,outcome->{
|
||||
if(outcome.status()==ArmorShop.Status.APPLIED)player.sendMessage("Purchased "+title(product.tier().name())+" "+title(product.slot().name())+".");
|
||||
else{
|
||||
player.sendMessage("Armor purchase refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");
|
||||
if(outcome.status()==ArmorShop.Status.STALE||outcome.status()==ArmorShop.Status.SOLD_OUT)open(player,holder.shop());
|
||||
}
|
||||
});
|
||||
}catch(RuntimeException ignored){ }
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
|
||||
public void drag(InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
|
||||
private static String title(String value){String lower=value.toLowerCase(Locale.ROOT).replace('_',' ');
|
||||
return Character.toUpperCase(lower.charAt(0))+lower.substring(1);}
|
||||
@Override public void close(){closed=true;}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import java.util.*;
|
||||
/** Durable desired location for one administrator-managed Fantasy Armorer. */
|
||||
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
|
||||
@@ -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 Armorer reconciliation and native protection, distinct from Party Registrars. */
|
||||
public final class FantasyArmorerController implements Listener,AutoCloseable{
|
||||
private final Plugin plugin;private final FantasyArmorerDirectory directory;private final BiConsumer<Player,UUID> interactions;private final NamespacedKey idKey,typeKey;private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private boolean closed;public FantasyArmorerController(Plugin plugin,FantasyArmorerDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"fantasy_armorer_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}public boolean tagged(Entity entity){return "fantasy_armorer".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,FantasyArmorer>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&§ion(value.x())==chunk.getX()&§ion(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var target:desired.values())if(!retained.contains(target.id()))spawn(target);}
|
||||
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
|
||||
private void spawn(FantasyArmorer value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=section(value.x()),z=section(value.z());if(world.isChunkLoaded(x,z))world.spawn(location(value),Villager.class,villager->configure(villager,value));}private void configure(Villager villager,FantasyArmorer value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,"fantasy_armorer");data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Fantasy Armorer":value.name()));villager.setCustomNameVisible(true);}private Location location(FantasyArmorer value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}private static int section(double value){return((int)Math.floor(value))>>4;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
|
||||
@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.YamlFantasyArmorerRepository;import java.io.*;import java.util.*;import java.util.concurrent.*;
|
||||
/** Serialized durable Fantasy Armorer desired state. */
|
||||
public final class FantasyArmorerDirectory implements AutoCloseable{
|
||||
private final YamlFantasyArmorerRepository repository;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armorer-persistence"));private volatile List<FantasyArmorer> snapshot;private final Map<UUID,Integer> leases=new HashMap<>();private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();public FantasyArmorerDirectory(YamlFantasyArmorerRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}public List<FantasyArmorer> all(){return snapshot;}public Optional<FantasyArmorer> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
|
||||
public synchronized Optional<Lease> acquire(UUID id){if(closed||find(id).isEmpty())return Optional.empty();leases.merge(id,1,Integer::sum);return Optional.of(new Lease(id));}public final class Lease implements AutoCloseable{private final UUID id;private boolean released;private Lease(UUID id){this.id=id;}public FantasyArmorer shop(){return find(id).orElseThrow();}@Override public void close(){synchronized(FantasyArmorerDirectory.this){if(released)return;released=true;int remaining=leases.getOrDefault(id,0)-1;if(remaining<=0)leases.remove(id);else leases.put(id,remaining);}}}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> operation){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Armorer directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(operation.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -9,6 +9,25 @@ roleplay-disabled: false
|
||||
party-max-size: 6
|
||||
party-registration-price: 10
|
||||
party-leader-change-price: 10
|
||||
gear-price-leather: 2
|
||||
gear-price-copper: 10
|
||||
gear-price-gold: 25
|
||||
gear-price-iron: 75
|
||||
gear-price-diamond: 150
|
||||
gear-price-netherite: 500
|
||||
gear-price-variance-percent: 10
|
||||
gear-stock-leather-min: 25
|
||||
gear-stock-leather-max: 50
|
||||
gear-stock-copper-min: 20
|
||||
gear-stock-copper-max: 40
|
||||
gear-stock-gold-min: 15
|
||||
gear-stock-gold-max: 30
|
||||
gear-stock-iron-min: 10
|
||||
gear-stock-iron-max: 20
|
||||
gear-stock-diamond-min: 5
|
||||
gear-stock-diamond-max: 10
|
||||
gear-stock-netherite-min: 1
|
||||
gear-stock-netherite-max: 3
|
||||
sheet-resource-pack-url: ''
|
||||
sheet-resource-pack-sha1: ''
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value] | shop <place [name...]|remove <shop-uuid>|list|restock> | party-registrar <place [name...]|remove <registrar-uuid>|list>
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
|
||||
@@ -327,10 +327,12 @@ class CharacterBookNativeTest {
|
||||
assertTrue(pages.size() <= 100, "Keep each native linked-book view bounded");
|
||||
assertTrue(pages.get(0).getString().contains("STR: 0 [+1 STR]"));
|
||||
assertTrue(pages.get(0).getString().contains("INT: 12 [+1 INT]"));
|
||||
assertTrue(pages.get(1).getString().startsWith("Active abilities"));
|
||||
assertTrue(pages.get(1).getString().contains("3: No Ability"));
|
||||
assertTrue(pages.get(1).getString().startsWith("Current gear"));
|
||||
assertTrue(pages.get(1).getString().contains("Head: Cosmetic"));
|
||||
assertTrue(pages.get(2).getString().startsWith("Active abilities"));
|
||||
assertTrue(pages.get(2).getString().contains("3: No Ability"));
|
||||
int index = firstPageStarting(pages, "Ability index");
|
||||
assertTrue(index > 1);
|
||||
assertTrue(index > 2);
|
||||
var indexedNames = new java.util.ArrayList<String>();
|
||||
int partyPage = firstPageStarting(pages, "Party");
|
||||
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
|
||||
@@ -360,8 +362,8 @@ class CharacterBookNativeTest {
|
||||
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals("fireball", saved.loadout().get(0));
|
||||
var refreshed = nativePages(a);
|
||||
assertTrue(refreshed.get(1).getString().contains("3: Fireball"));
|
||||
var activeJump = changePage(refreshed.get(1), "Fireball");
|
||||
assertTrue(refreshed.get(2).getString().contains("3: Fireball"));
|
||||
var activeJump = changePage(refreshed.get(2), "Fireball");
|
||||
assertNotNull(activeJump);
|
||||
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
|
||||
}
|
||||
|
||||
@@ -53,7 +53,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("config","experience","give","inspect","party-registrar","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("config","experience","give","inspect","party-registrar","recover","shop"),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"}));
|
||||
@@ -94,7 +94,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("config", "experience", "give", "inspect", "party-registrar", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"), 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", ""}));
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;import games.dmg.fantasystick.items.GearItems;import games.dmg.fantasystick.persistence.*;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import org.bukkit.Material;import org.bukkit.event.player.PlayerDropItemEvent;import org.bukkit.inventory.ItemStack;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
|
||||
class FantasyArmorNativeTest{
|
||||
@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void everyTierAndHelmetFreeSlotProducesBoundUnbreakableUnenchantedNativeArmor()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var gear=new GearItems(f.plugin);for(var slot:GearSlot.values())for(var tier:GearTier.values())if(tier!=GearTier.NONE){var item=gear.create(slot,tier,UUID.randomUUID(),UUID.randomUUID());assertEquals(Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";}),item.getType());assertTrue(item.getItemMeta().isUnbreakable());assertTrue(item.getEnchantments().isEmpty());}assertEquals(3,GearSlot.values().length);}}
|
||||
@Test void purchasedGearMaterializesOnlyInFantasyModeIsContainedAndOrdinaryArmorRestores()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);var repository=new YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();var funded=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(1000),true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());repository.replace(funded,old.revision());try(var storage=new CharacterStorage(repository,java.time.Clock.systemUTC())){storage.purchaseGear(funded.id(),funded.revision(),UUID.randomUUID(),GearSlot.CHESTPLATE,GearTier.IRON,BigInteger.valueOf(75)).join();}var ordinary=new ItemStack(Material.DIAMOND_CHESTPLATE);actor.core.inventory.setChestplate(ordinary);f.activate(actor);var fantasy=actor.core.inventory.getChestplate();assertEquals(Material.IRON_CHESTPLATE,fantasy.getType());assertTrue(fantasy.getItemMeta().isUnbreakable());var dropped=mock(org.bukkit.entity.Item.class);when(dropped.getItemStack()).thenReturn(fantasy);var event=new PlayerDropItemEvent(actor.player,dropped);f.dispatcher.accept(event);assertTrue(event.isCancelled());actor.core.inventory.setItemInOffHand(null);f.changed(actor);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(actor);assertEquals(ordinary,actor.core.inventory.getChestplate());assertFalse(Arrays.stream(actor.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new GearItems(f.plugin).gear(item)));actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(repository.load(old.id()).orElseThrow()));f.activate(actor);assertEquals(Material.IRON_CHESTPLATE,actor.core.inventory.getChestplate().getType());actor.core.inventory.setItemInOffHand(null);f.changed(actor);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(actor);assertEquals(ordinary,actor.core.inventory.getChestplate());}}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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 java.math.BigInteger;import java.nio.file.Path;import java.util.*;import org.bukkit.*;import org.bukkit.entity.*;import org.bukkit.event.inventory.InventoryClickEvent;import org.bukkit.event.player.PlayerInteractEntityEvent;import org.bukkit.inventory.EquipmentSlot;import org.bukkit.persistence.*;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
|
||||
class FantasyArmorerNativeTest{
|
||||
@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@SuppressWarnings("unchecked")@Test void adminPlacementDistinctNpcAndActiveShopPurchaseUseActualRegisteredPath()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");var console=mock(org.bukkit.command.CommandSender.class);when(console.hasPermission("fantasystick.admin")).thenReturn(true);var consoleMessages=new ArrayList<String>();doAnswer(call->{consoleMessages.add(call.getArgument(0));return null;}).when(console).sendMessage(anyString());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("gear-price-copper","gear-price-diamond","gear-price-gold","gear-price-iron","gear-price-leather","gear-price-netherite","gear-price-variance-percent"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","gear-price-"}));assertEquals(List.of("gear-stock-copper-max","gear-stock-copper-min","gear-stock-diamond-max","gear-stock-diamond-min","gear-stock-gold-max","gear-stock-gold-min","gear-stock-iron-max","gear-stock-iron-min","gear-stock-leather-max","gear-stock-leather-min","gear-stock-netherite-max","gear-stock-netherite-min"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","gear-stock-"}));assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"shop",""}));assertEquals(List.of("list","remove","restock"),f.command.tabComplete(console,"fantasystick",new String[]{"shop",""}));Runnable task;f.command.execute(console,"fantasystick",new String[]{"config","gear-price-variance-percent","0"});for(int attempt=0;attempt<10&&consoleMessages.stream().noneMatch(message->message.contains("Saved gear-price-variance-percent=0"));attempt++){task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}f.command.execute(console,"fantasystick",new String[]{"config","gear-stock-iron-min","20"});for(int attempt=0;attempt<10&&consoleMessages.stream().noneMatch(message->message.contains("Saved gear-stock-iron-min=20"));attempt++){task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}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[]{"shop","place","Forge"});task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();var directory=(games.dmg.fantasystick.shop.FantasyArmorerDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"armorers");var shop=directory.all().getFirst();assertEquals("Forge",shop.name());assertEquals(List.of(shop.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"shop","remove",shop.id().toString().substring(0,8)}));var armorer=(games.dmg.fantasystick.shop.FantasyArmorerController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"armorerController");var registrar=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(armorer.tagged(villager));assertFalse(registrar.tagged(villager));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});armorer.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager,times(2)).setAI(false);verify(villager,times(2)).setGravity(false);verify(villager,times(2)).setSilent(true);verify(villager,times(2)).setInvulnerable(true);verify(villager,times(2)).setCollidable(false);var damage=mock(org.bukkit.event.entity.EntityDamageEvent.class);when(damage.getEntity()Line truncated
|
||||
}
|
||||
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(11, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects and session particles");
|
||||
assertEquals(13, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor-market/menu 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("config", "experience", "give", "inspect", "party-registrar", "recover"),
|
||||
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
|
||||
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());
|
||||
|
||||
@@ -52,7 +52,7 @@ class PartyRegistrarNativeTest {
|
||||
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","place","Central"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();verify(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager).setAI(false);verify(villager).setInvulnerable(true);
|
||||
var directory=(games.dmg.fantasystick.party.PartyRegistrarDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrars");var registrar=directory.all().getFirst();assertEquals("Central",registrar.name());
|
||||
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.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(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
|
||||
controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));assertFalse(actor.inventories.isEmpty());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());
|
||||
int openMenus=actor.inventories.size(),openBooks=actor.books.size();var mainInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);f.dispatcher.accept(mainInteraction);var offhandDuplicate=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(offhandDuplicate);assertTrue(mainInteraction.isCancelled());assertTrue(offhandDuplicate.isCancelled());assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());var vetoedInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);vetoedInteraction.setCancelled(true);f.dispatcher.accept(vetoedInteraction);assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());
|
||||
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.TEN,characters.find(old.id()).join().orElseThrow().coins());
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.ZERO,characters.find(old.id()).join().orElseThrow().coins());
|
||||
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","list"});assertTrue(actor.messages.stream().anyMatch(message->message.contains(registrar.id().toString())&&message.contains("Central")));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});controller.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));var damage=new org.bukkit.event.entity.EntityDamageEvent(villager,org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,1);controller.damage(damage);assertTrue(damage.isCancelled());var combust=new org.bukkit.event.entity.EntityCombustEvent(villager,5);controller.combust(combust);assertTrue(combust.isCancelled());var interaction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);controller.interact(interaction);assertTrue(interaction.isCancelled());var leash=new org.bukkit.event.entity.PlayerLeashEntityEvent(villager,actor.player,actor.player,EquipmentSlot.HAND);controller.leash(leash);assertTrue(leash.isCancelled());
|
||||
|
||||
@@ -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("config", "experience", "give", "inspect", "party-registrar", "recover"),
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
|
||||
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());
|
||||
|
||||
@@ -128,7 +128,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("config", "experience", "give", "inspect", "party-registrar", "recover"),
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
|
||||
|
||||
@@ -91,12 +91,17 @@ class TailBooksNativeTest {
|
||||
old.unspentPoints(), old.health(), Set.of("second_wind", "unknown:雪"),
|
||||
List.of("second_wind", "unknown:雪", "second_wind", "", "", "", ""), old.passives(),
|
||||
Map.of("second_wind", expiry, "retired_ability", expiry.plusSeconds(60)), old.restrictedKills(),
|
||||
true, old.deathId(), old.diedAt(), BigInteger.valueOf(123), true, old.rewardReceipts());
|
||||
true, old.deathId(), old.diedAt(), BigInteger.valueOf(123), true, old.rewardReceipts(), BigInteger.ZERO,
|
||||
new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.DIAMOND,
|
||||
games.dmg.fantasystick.persistence.GearTier.GOLD, games.dmg.fantasystick.persistence.GearTier.LEATHER));
|
||||
var book = new TailBooks().create(saved);
|
||||
var pages = nativePages(book);
|
||||
String summary = pages.getFirst().replace("\n", "");
|
||||
for (String expected : List.of("Tail of Ari", "Level: 2", "XP: 100", "Unspent points: 30", "Coins: 123",
|
||||
"STR: 0", "DEX: 0", "CON: 10", "INT: 0", "WIS: 0", "CHR: 0")) assertTrue(summary.contains(expected), expected);
|
||||
String complete = String.join("", pages).replace("\n", "");
|
||||
for (String expected : List.of("Current gear", "Head: Cosmetic only", "Chestplate: Diamond", "Leggings: Gold", "Boots: Leather"))
|
||||
assertTrue(complete.contains(expected), expected);
|
||||
String wind = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: second_wind")).findFirst().orElseThrow();
|
||||
assertTrue(wind.contains("Slots: 1, 3")); assertTrue(wind.contains(expiry.toString()));
|
||||
assertFalse(wind.contains("unknown:雪"), "Each learned ability starts its own page/section");
|
||||
|
||||
@@ -32,6 +32,7 @@ class DistributionTest {
|
||||
assertEquals(java.util.Set.of("fantasystick"), metadata.getCommands().keySet());
|
||||
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("experience give"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("shop <place [name...]|remove <shop-uuid>|list|restock>"));
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
|
||||
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
|
||||
@@ -89,6 +90,15 @@ class DistributionTest {
|
||||
"boss-coin-drop-min: 25", "boss-coin-drop-max: 50")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged coin default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("gear-price-variance-percent: 10",
|
||||
"gear-stock-leather-min: 25", "gear-stock-leather-max: 50",
|
||||
"gear-stock-copper-min: 20", "gear-stock-copper-max: 40",
|
||||
"gear-stock-gold-min: 15", "gear-stock-gold-max: 30",
|
||||
"gear-stock-iron-min: 10", "gear-stock-iron-max: 20",
|
||||
"gear-stock-diamond-min: 5", "gear-stock-diamond-max: 10",
|
||||
"gear-stock-netherite-min: 1", "gear-stock-netherite-max: 3")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged armor-market default: " + setting);
|
||||
}
|
||||
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
|
||||
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
|
||||
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 30",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorProduct;
|
||||
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 ArmorPurchaseOperationStoreTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test
|
||||
void durablePlansRoundTripAndCompleteIdempotently() {
|
||||
var plan = new ArmorPurchaseOperationStore.Plan(UUID.randomUUID(), UUID.randomUUID(), BigInteger.TEN,
|
||||
UUID.randomUUID(), new ArmorProduct(GearSlot.BOOTS, GearTier.DIAMOND), BigInteger.valueOf(151));
|
||||
try (var store = new ArmorPurchaseOperationStore(data)) {
|
||||
assertEquals(plan, store.prepare(plan).toCompletableFuture().join());
|
||||
}
|
||||
try (var restarted = new ArmorPurchaseOperationStore(data)) {
|
||||
assertEquals(java.util.List.of(plan), restarted.load().toCompletableFuture().join());
|
||||
restarted.complete(plan.id()).toCompletableFuture().join();
|
||||
restarted.complete(plan.id()).toCompletableFuture().join();
|
||||
assertTrue(restarted.load().toCompletableFuture().join().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterGearTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void strictAtomicPurchasesPersistGearAndCoinsAcrossRestart()throws Exception{
|
||||
var repository=new YamlCharacterRepository(data);var created=new CharacterFactory().create(ProgressionRules.defaults());repository.create(created);var funded=funded(created,BigInteger.valueOf(1000));repository.replace(funded,created.revision());UUID operation=UUID.randomUUID();
|
||||
try(var storage=new CharacterStorage(repository,Clock.systemUTC())){
|
||||
var bought=storage.purchaseGear(funded.id(),funded.revision(),operation,GearSlot.CHESTPLATE,GearTier.IRON,BigInteger.valueOf(75)).join();assertEquals(CharacterStorage.GearPurchaseStatus.APPLIED,bought.status());assertEquals(BigInteger.valueOf(925),bought.record().orElseThrow().coins());assertEquals(GearTier.IRON,bought.record().orElseThrow().gear().tier(GearSlot.CHESTPLATE));
|
||||
assertEquals(CharacterStorage.GearPurchaseStatus.DUPLICATE,storage.purchaseGear(funded.id(),funded.revision(),operation,GearSlot.CHESTPLATE,GearTier.IRON,BigInteger.valueOf(75)).join().status());
|
||||
var current=bought.record().orElseThrow();assertEquals(CharacterStorage.GearPurchaseStatus.NOT_UPGRADE,storage.purchaseGear(current.id(),current.revision(),UUID.randomUUID(),GearSlot.CHESTPLATE,GearTier.GOLD,BigInteger.valueOf(25)).join().status());assertEquals(CharacterStorage.GearPurchaseStatus.INSUFFICIENT,storage.purchaseGear(current.id(),current.revision(),UUID.randomUUID(),GearSlot.BOOTS,GearTier.NETHERITE,BigInteger.valueOf(1000)).join().status());
|
||||
}
|
||||
var reloaded=repository.load(funded.id()).orElseThrow();assertEquals(GearTier.IRON,reloaded.gear().chestplate());assertEquals(GearTier.NONE,reloaded.gear().leggings());assertEquals(GearTier.NONE,reloaded.gear().boots());assertEquals(BigInteger.valueOf(925),reloaded.coins());try(var storage=new CharacterStorage(repository,Clock.systemUTC())){var dead=storage.recordDeath(reloaded.id(),true).join().orElseThrow();assertEquals(GearTier.IRON,dead.gear().chestplate());assertEquals(BigInteger.ZERO,dead.coins());var revived=storage.restoreDeathRecord(dead.id(),dead.deathId()).join().orElseThrow();assertEquals(GearTier.IRON,revived.gear().chestplate());}
|
||||
}
|
||||
|
||||
private static CharacterRecord funded(CharacterRecord old,BigInteger coins){return 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,coins,true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());}
|
||||
|
||||
@Test void concurrentRequestsCannotDoubleSpendOrInstallTwoUpgrades()throws Exception{var repository=new YamlCharacterRepository(data);var created=new CharacterFactory().create(ProgressionRules.defaults());repository.create(created);var funded=funded(created,BigInteger.valueOf(100));repository.replace(funded,created.revision());try(var storage=new CharacterStorage(repository,Clock.systemUTC())){var first=storage.purchaseGear(funded.id(),funded.revision(),UUID.randomUUID(),GearSlot.BOOTS,GearTier.LEATHER,BigInteger.TWO);var second=storage.purchaseGear(funded.id(),funded.revision(),UUID.randomUUID(),GearSlot.BOOTS,GearTier.IRON,BigInteger.valueOf(75));assertEquals(CharacterStorage.GearPurchaseStatus.APPLIED,first.join().status());assertEquals(CharacterStorage.GearPurchaseStatus.STALE,second.join().status());var saved=storage.find(funded.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(98),saved.coins());assertEquals(GearTier.LEATHER,saved.gear().boots());}}
|
||||
|
||||
@Test void failedCharacterWriteChargesAndChangesNothing()throws Exception{var repository=new YamlCharacterRepository(data);var created=new CharacterFactory().create(ProgressionRules.defaults());repository.create(created);var funded=funded(created,BigInteger.valueOf(100));repository.replace(funded,created.revision());var failing=new YamlCharacterRepository(data,(source,target)->{throw new java.io.IOException("forced");});try(var storage=new CharacterStorage(failing,Clock.systemUTC())){assertThrows(java.util.concurrent.CompletionException.class,()->storage.purchaseGear(funded.id(),funded.revision(),UUID.randomUUID(),GearSlot.BOOTS,GearTier.LEATHER,BigInteger.TWO).join());}var unchanged=repository.load(funded.id()).orElseThrow();assertEquals(BigInteger.valueOf(100),unchanged.coins());assertEquals(GearTier.NONE,unchanged.gear().boots());}
|
||||
|
||||
@Test void validatesHelmetFreeSlotsAndTierOrder(){
|
||||
assertEquals(java.util.List.of(GearSlot.CHESTPLATE,GearSlot.LEGGINGS,GearSlot.BOOTS),java.util.List.of(GearSlot.values()));
|
||||
assertTrue(GearTier.NETHERITE.higherThan(GearTier.DIAMOND));assertFalse(GearTier.COPPER.higherThan(GearTier.GOLD));assertThrows(IllegalArgumentException.class,()->new CharacterGear(null,GearTier.NONE,GearTier.NONE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class GearSettingsTest {
|
||||
@TempDir Path data;
|
||||
@Test void publishesAllSixDocumentedBasePrices(){var expected=Map.of("gear-price-leather","2","gear-price-copper","10","gear-price-gold","25","gear-price-iron","75","gear-price-diamond","150","gear-price-netherite","500");assertEquals(expected,expected.keySet().stream().collect(java.util.stream.Collectors.toMap(key->key,GearSettings::defaultValue)));}
|
||||
@Test void publishesDailyMarketDefaultsAndValidatesRanges(){
|
||||
var defaults=GearSettings.keys().stream().collect(java.util.stream.Collectors.toMap(key->key,GearSettings::defaultValue));
|
||||
assertEquals("10",defaults.get("gear-price-variance-percent"));
|
||||
assertEquals(Map.ofEntries(
|
||||
Map.entry("gear-stock-leather-min","25"),Map.entry("gear-stock-leather-max","50"),
|
||||
Map.entry("gear-stock-copper-min","20"),Map.entry("gear-stock-copper-max","40"),
|
||||
Map.entry("gear-stock-gold-min","15"),Map.entry("gear-stock-gold-max","30"),
|
||||
Map.entry("gear-stock-iron-min","10"),Map.entry("gear-stock-iron-max","20"),
|
||||
Map.entry("gear-stock-diamond-min","5"),Map.entry("gear-stock-diamond-max","10"),
|
||||
Map.entry("gear-stock-netherite-min","1"),Map.entry("gear-stock-netherite-max","3")),
|
||||
defaults.entrySet().stream().filter(entry->entry.getKey().startsWith("gear-stock-"))
|
||||
.collect(java.util.stream.Collectors.toMap(Map.Entry::getKey,Map.Entry::getValue)));
|
||||
try(var settings=new GearSettings(data,defaults)){
|
||||
var rules=settings.rules();
|
||||
assertEquals(10,rules.variancePercent());
|
||||
assertEquals(25,rules.stock().get(GearTier.LEATHER).minimum());
|
||||
assertEquals(3,rules.stock().get(GearTier.NETHERITE).maximum());
|
||||
assertThrows(IllegalArgumentException.class,()->settings.set("gear-price-variance-percent","101"));
|
||||
assertThrows(IllegalArgumentException.class,()->settings.set("gear-stock-leather-min","51"));
|
||||
assertThrows(IllegalArgumentException.class,()->settings.set("gear-stock-leather-min","0"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedSavePreservesEffectiveValuesAndUnrelatedState()throws Exception{
|
||||
var blocked=data.resolve("blocked");Files.writeString(blocked,"not a directory");
|
||||
var defaults=GearSettings.keys().stream().collect(java.util.stream.Collectors.toMap(key->key,GearSettings::defaultValue));
|
||||
try(var settings=new GearSettings(blocked,defaults)){
|
||||
assertThrows(java.util.concurrent.CompletionException.class,()->settings.set("gear-price-variance-percent","20").toCompletableFuture().join());
|
||||
assertEquals("10",settings.value("gear-price-variance-percent"));
|
||||
assertEquals("not a directory",Files.readString(blocked));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void validatesPersistsPublishesAndPreservesUnrelatedKeys()throws Exception{
|
||||
Files.writeString(data.resolve("config.yml"),"unrelated: keep\ngear-price-leather: 2\n");
|
||||
try(var settings=new GearSettings(data,Map.of("gear-price-leather","2","gear-price-copper","10","gear-price-gold","25","gear-price-iron","75","gear-price-diamond","150","gear-price-netherite","500"))){assertEquals(500,settings.price(GearTier.NETHERITE));var update=settings.set("gear-price-netherite","2147483647").toCompletableFuture().join();assertEquals(500,settings.price(GearTier.NETHERITE));assertTrue(settings.publish(update));assertEquals(Integer.MAX_VALUE,settings.price(GearTier.NETHERITE));assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));assertThrows(IllegalArgumentException.class,()->settings.set("gear-price-gold","-1"));assertThrows(IllegalArgumentException.class,()->settings.set("gear-price-gold","1.5"));}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.shop.*;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class YamlArmorCatalogRepositoryTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test
|
||||
void durablyRoundTripsOneCompleteCatalogWithUtcMetadataAndEveryProduct() throws Exception {
|
||||
var products = new LinkedHashMap<ArmorProduct, ArmorCatalog.Offer>();
|
||||
int value = 1;
|
||||
for (var slot : GearSlot.values()) for (var tier : GearTier.values()) {
|
||||
if (tier != GearTier.NONE) products.put(new ArmorProduct(slot, tier),
|
||||
new ArmorCatalog.Offer(BigInteger.valueOf(value), value++));
|
||||
}
|
||||
var catalog = new ArmorCatalog(LocalDate.of(2026, 9, 21), UUID.randomUUID(),
|
||||
Instant.parse("2026-09-21T12:34:56Z"), ArmorCatalog.RefreshReason.ADMINISTRATIVE, products);
|
||||
var repository = new YamlArmorCatalogRepository(data);
|
||||
|
||||
repository.save(catalog);
|
||||
|
||||
assertEquals(catalog, new YamlArmorCatalogRepository(data).load().orElseThrow());
|
||||
var yaml = Files.readString(data.resolve("armor-catalog.yml"));
|
||||
assertTrue(yaml.contains("refreshed-at: '2026-09-21T12:34:56Z'"));
|
||||
assertTrue(yaml.contains("CHESTPLATE-LEATHER"));
|
||||
assertTrue(yaml.contains("BOOTS-NETHERITE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import static org.junit.jupiter.api.Assertions.*;import games.dmg.fantasystick.shop.FantasyArmorer;import java.nio.file.Path;import java.util.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class YamlFantasyArmorerRepositoryTest{@TempDir Path data;@Test void preservesExactDurableNamedLocationsAndRejectsDuplicates()throws Exception{var repository=new YamlFantasyArmorerRepository(data);var value=new FantasyArmorer(UUID.randomUUID(),"Forge",UUID.randomUUID(),1.25,64,-2.5,90,10);repository.save(List.of(value));assertEquals(List.of(value),repository.load());assertThrows(IllegalArgumentException.class,()->repository.save(List.of(value,value)));}
|
||||
@Test void activePurchaseLeasePreventsRemovalUntilItFinishes()throws Exception{try(var directory=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new YamlFantasyArmorerRepository(data))){var shop=directory.place("Forge",UUID.randomUUID(),1,2,3,4,5).join();var lease=directory.acquire(shop.id()).orElseThrow();assertThrows(java.util.concurrent.CompletionException.class,()->directory.remove(shop.id()).join());assertEquals(shop,directory.find(shop.id()).orElseThrow());lease.close();assertEquals(shop,directory.remove(shop.id()).join().orElseThrow());assertTrue(directory.find(shop.id()).isEmpty());}}}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.GearSlot;
|
||||
import games.dmg.fantasystick.persistence.GearTier;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.EnumMap;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ArmorCatalogGeneratorTest {
|
||||
@Test
|
||||
void independentlyGeneratesAllProductsWithExactPriceAndStockEndpoints() {
|
||||
var bases = new EnumMap<GearTier, BigInteger>(GearTier.class);
|
||||
var stock = new EnumMap<GearTier, ArmorMarketRules.StockRange>(GearTier.class);
|
||||
for (var tier : GearTier.values()) {
|
||||
if (tier == GearTier.NONE) continue;
|
||||
bases.put(tier, tier == GearTier.LEATHER ? BigInteger.ZERO : BigInteger.valueOf(101));
|
||||
stock.put(tier, new ArmorMarketRules.StockRange(2, 4));
|
||||
}
|
||||
var rules = new ArmorMarketRules(bases, 10, stock);
|
||||
var day = LocalDate.of(2026, 9, 21);
|
||||
var refreshedAt = Instant.parse("2026-09-21T00:00:00Z");
|
||||
var minimum = ArmorCatalogGenerator.generate(day, refreshedAt, ArmorCatalog.RefreshReason.SCHEDULED,
|
||||
rules, (lower, upper) -> lower);
|
||||
var maximum = ArmorCatalogGenerator.generate(day, refreshedAt, ArmorCatalog.RefreshReason.ADMINISTRATIVE,
|
||||
rules, (lower, upper) -> upper);
|
||||
|
||||
assertEquals(18, minimum.products().size());
|
||||
assertEquals(BigInteger.ONE,
|
||||
minimum.offer(new ArmorProduct(GearSlot.CHESTPLATE, GearTier.LEATHER)).price());
|
||||
assertEquals(BigInteger.valueOf(91),
|
||||
minimum.offer(new ArmorProduct(GearSlot.LEGGINGS, GearTier.COPPER)).price());
|
||||
assertEquals(BigInteger.valueOf(112),
|
||||
maximum.offer(new ArmorProduct(GearSlot.BOOTS, GearTier.NETHERITE)).price());
|
||||
assertTrue(minimum.products().values().stream().allMatch(offer -> offer.remaining() == 2));
|
||||
assertTrue(maximum.products().values().stream().allMatch(offer -> offer.remaining() == 4));
|
||||
assertNotEquals(minimum.revision(), maximum.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ArmorMarketTest {
|
||||
@TempDir Path data;
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant instant;
|
||||
private MutableClock(Instant instant){this.instant=instant;}
|
||||
void set(Instant value){instant=value;}
|
||||
@Override public java.time.ZoneId getZone(){return ZoneOffset.UTC;}
|
||||
@Override public Clock withZone(java.time.ZoneId zone){return this;}
|
||||
@Override public Instant instant(){return instant;}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAdministrativeRestockLeavesThePriorCatalogPublishedAndDurable() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of());var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
|
||||
var durable=new YamlArmorCatalogRepository(data);
|
||||
class FailingCatalogs implements ArmorCatalogRepository{
|
||||
boolean fail;
|
||||
@Override public java.util.Optional<ArmorCatalog> load()throws java.io.IOException{return durable.load();}
|
||||
@Override public void save(ArmorCatalog catalog)throws java.io.IOException{if(fail)throw new java.io.IOException("injected restock failure");durable.save(catalog);}
|
||||
}
|
||||
var catalogs=new FailingCatalogs();
|
||||
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);var market=new ArmorMarket(catalogs,
|
||||
new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
var prior=market.current().toCompletableFuture().join();catalogs.fail=true;
|
||||
assertThrows(java.util.concurrent.CompletionException.class,()->market.restock().toCompletableFuture().join());
|
||||
assertEquals(prior,market.snapshot().orElseThrow());
|
||||
assertEquals(prior,durable.load().orElseThrow());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgedConfigurationChangesWaitForTheNextRestock() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of());
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
|
||||
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);
|
||||
var market=new ArmorMarket(new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),
|
||||
characters,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
var product=new ArmorProduct(GearSlot.CHESTPLATE,GearTier.LEATHER);
|
||||
var current=market.current().toCompletableFuture().join();
|
||||
var variance=settings.set("gear-price-variance-percent","0").toCompletableFuture().join();
|
||||
assertTrue(settings.publish(variance));
|
||||
var base=settings.set("gear-price-leather","100").toCompletableFuture().join();
|
||||
assertTrue(settings.publish(base));
|
||||
assertEquals(current.offer(product).price(),market.current().toCompletableFuture().join().offer(product).price());
|
||||
assertEquals(java.math.BigInteger.valueOf(100),market.restock().toCompletableFuture().join().offer(product).price());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void administrativeRestockRerollsImmediatelyWithoutShiftingMidnightUtcRollover() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of());
|
||||
var clock=new MutableClock(Instant.parse("2026-09-21T23:59:30Z"));
|
||||
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);
|
||||
var market=new ArmorMarket(new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),
|
||||
characters,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
var initial=market.current().toCompletableFuture().join();
|
||||
var manual=market.restock().toCompletableFuture().join();
|
||||
assertEquals(ArmorCatalog.RefreshReason.ADMINISTRATIVE,manual.reason());
|
||||
assertNotEquals(initial.revision(),manual.revision());
|
||||
assertEquals(Instant.parse("2026-09-22T00:00:00Z"),ArmorMarket.nextScheduledRestock(clock.instant()));
|
||||
|
||||
clock.set(Instant.parse("2026-09-22T00:00:00Z"));
|
||||
var scheduled=market.current().toCompletableFuture().join();
|
||||
assertEquals(java.time.LocalDate.of(2026,9,22),scheduled.day());
|
||||
assertEquals(ArmorCatalog.RefreshReason.SCHEDULED,scheduled.reason());
|
||||
assertNotEquals(manual.revision(),scheduled.revision());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restartCompletesAReservedPurchaseAfterCatalogSettlementFailureWithoutDoubleChargeOrStockLoss() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of());
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
|
||||
var characterRepository=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,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),
|
||||
java.math.BigInteger.ZERO,CharacterGear.none());
|
||||
var durable=new YamlArmorCatalogRepository(data);
|
||||
class FailingCatalogs implements ArmorCatalogRepository {
|
||||
int saves;
|
||||
@Override public java.util.Optional<ArmorCatalog> load()throws java.io.IOException{return durable.load();}
|
||||
@Override public void save(ArmorCatalog catalog)throws java.io.IOException{
|
||||
if(++saves==3)throw new java.io.IOException("injected settlement failure");
|
||||
durable.save(catalog);
|
||||
}
|
||||
}
|
||||
try(var characters=new CharacterStorage(characterRepository,clock)){
|
||||
characters.create(character).join();
|
||||
var failing=new FailingCatalogs();
|
||||
try(var market=new ArmorMarket(failing,new ArmorPurchaseOperationStore(data),characters,settings::rules,
|
||||
clock,(minimum,maximum)->minimum)){
|
||||
var catalog=market.current().toCompletableFuture().join();
|
||||
var product=new ArmorProduct(GearSlot.BOOTS,GearTier.LEATHER);
|
||||
assertThrows(java.util.concurrent.CompletionException.class,()->market.purchase(character.id(),
|
||||
character.revision(),catalog.revision(),product).toCompletableFuture().join());
|
||||
assertEquals(ArmorMarket.PurchaseStatus.FAILED,market.purchase(character.id(),character.revision(),
|
||||
catalog.revision(),product).toCompletableFuture().join().status(),
|
||||
"Unresolved persistence must suspend later market mutations");
|
||||
}
|
||||
}
|
||||
try(var characters=new CharacterStorage(characterRepository,clock);
|
||||
var recovered=new ArmorMarket(durable,new ArmorPurchaseOperationStore(data),characters,settings::rules,
|
||||
clock,(minimum,maximum)->maximum)){
|
||||
var catalog=recovered.current().toCompletableFuture().join();
|
||||
var saved=characters.find(character.id()).join().orElseThrow();
|
||||
assertEquals(java.math.BigInteger.valueOf(98),saved.coins());
|
||||
assertEquals(GearTier.LEATHER,saved.gear().boots());
|
||||
assertEquals(24,catalog.offer(new ArmorProduct(GearSlot.BOOTS,GearTier.LEATHER)).remaining());
|
||||
assertTrue(catalog.transactions().isEmpty());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleCatalogGenerationRefusesWithoutCharacterOrStockMutation() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of());var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
|
||||
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,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),java.math.BigInteger.ZERO,CharacterGear.none());
|
||||
try(var characters=new CharacterStorage(repository,clock);var market=new ArmorMarket(new YamlArmorCatalogRepository(data),
|
||||
new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
characters.create(character).join();var old=market.current().toCompletableFuture().join();
|
||||
var current=market.restock().toCompletableFuture().join();var product=new ArmorProduct(GearSlot.BOOTS,GearTier.COPPER);
|
||||
var outcome=market.purchase(character.id(),character.revision(),old.revision(),product).toCompletableFuture().join();
|
||||
assertEquals(ArmorMarket.PurchaseStatus.STALE,outcome.status());
|
||||
assertEquals(current.offer(product),outcome.catalog().offer(product));
|
||||
assertEquals(character,characters.find(character.id()).join().orElseThrow());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void insufficientAndInvalidUpgradesRestoreReservedStockWithoutCharging() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of("gear-stock-diamond-min","1","gear-stock-diamond-max","1"));
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
|
||||
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,java.math.BigInteger.ZERO,true,java.util.Set.of(),
|
||||
java.math.BigInteger.ZERO,CharacterGear.none());
|
||||
try(var characters=new CharacterStorage(repository,clock);
|
||||
var market=new ArmorMarket(new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),
|
||||
characters,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
characters.create(character).join();var catalog=market.current().toCompletableFuture().join();
|
||||
var product=new ArmorProduct(GearSlot.LEGGINGS,GearTier.DIAMOND);
|
||||
var outcome=market.purchase(character.id(),character.revision(),catalog.revision(),product).toCompletableFuture().join();
|
||||
assertEquals(ArmorMarket.PurchaseStatus.INSUFFICIENT,outcome.status());
|
||||
assertEquals(1,outcome.catalog().offer(product).remaining());
|
||||
assertEquals(java.math.BigInteger.ZERO,characters.find(character.id()).join().orElseThrow().coins());
|
||||
assertTrue(outcome.catalog().transactions().isEmpty());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedConcurrentRequestsChargeAndDecrementOnlyOnce() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of());var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
|
||||
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,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),java.math.BigInteger.ZERO,CharacterGear.none());
|
||||
try(var characters=new CharacterStorage(repository,clock);var market=new ArmorMarket(new YamlArmorCatalogRepository(data),
|
||||
new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
characters.create(character).join();var catalog=market.current().toCompletableFuture().join();
|
||||
var product=new ArmorProduct(GearSlot.LEGGINGS,GearTier.LEATHER);int initial=catalog.offer(product).remaining();
|
||||
var first=market.purchase(character.id(),character.revision(),catalog.revision(),product).toCompletableFuture();
|
||||
var repeat=market.purchase(character.id(),character.revision(),catalog.revision(),product).toCompletableFuture();
|
||||
assertEquals(java.util.Set.of(ArmorMarket.PurchaseStatus.APPLIED,ArmorMarket.PurchaseStatus.STALE),
|
||||
java.util.Set.of(first.join().status(),repeat.join().status()));
|
||||
assertEquals(initial-1,market.current().toCompletableFuture().join().offer(product).remaining());
|
||||
assertEquals(java.math.BigInteger.valueOf(98),characters.find(character.id()).join().orElseThrow().coins());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentBuyersCannotOversellOneSharedRemainingItem() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of("gear-stock-leather-min","1","gear-stock-leather-max","1"));
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC);
|
||||
var repository=new YamlCharacterRepository(data);
|
||||
var first=new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var second=new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
first=new CharacterRecord(first.id(),first.name(),first.revision(),first.stats(),first.level(),first.experience(),
|
||||
first.unspentPoints(),first.health(),first.learned(),first.loadout(),first.passives(),first.cooldowns(),
|
||||
first.restrictedKills(),false,null,null,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),
|
||||
java.math.BigInteger.ZERO,CharacterGear.none());
|
||||
second=new CharacterRecord(second.id(),second.name(),second.revision(),second.stats(),second.level(),second.experience(),
|
||||
second.unspentPoints(),second.health(),second.learned(),second.loadout(),second.passives(),second.cooldowns(),
|
||||
second.restrictedKills(),false,null,null,java.math.BigInteger.valueOf(100),true,java.util.Set.of(),
|
||||
java.math.BigInteger.ZERO,CharacterGear.none());
|
||||
try(var characters=new CharacterStorage(repository,clock);
|
||||
var market=new ArmorMarket(new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),
|
||||
characters,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
characters.create(first).join();characters.create(second).join();
|
||||
var catalog=market.current().toCompletableFuture().join();
|
||||
var product=new ArmorProduct(GearSlot.CHESTPLATE,GearTier.LEATHER);
|
||||
var one=market.purchase(first.id(),first.revision(),catalog.revision(),product).toCompletableFuture();
|
||||
var two=market.purchase(second.id(),second.revision(),catalog.revision(),product).toCompletableFuture();
|
||||
var statuses=java.util.Set.of(one.join().status(),two.join().status());
|
||||
assertEquals(java.util.Set.of(ArmorMarket.PurchaseStatus.APPLIED,ArmorMarket.PurchaseStatus.SOLD_OUT),statuses);
|
||||
assertEquals(0,market.current().toCompletableFuture().join().offer(product).remaining());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void purchaseRecoverablyConsumesSharedStockAndAtomicallyChargesAndUpgradesCharacter() throws Exception {
|
||||
var settings = new GearSettings(data, Map.of());
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"), ZoneOffset.UTC);
|
||||
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, java.math.BigInteger.valueOf(100), true,
|
||||
java.util.Set.of(), java.math.BigInteger.ZERO, CharacterGear.none());
|
||||
try (var characters = new CharacterStorage(repository, clock);
|
||||
var market = new ArmorMarket(new YamlArmorCatalogRepository(data),
|
||||
new ArmorPurchaseOperationStore(data), characters, settings::rules, clock,
|
||||
(minimum, maximum) -> minimum)) {
|
||||
characters.create(character).join();
|
||||
var catalog = market.current().toCompletableFuture().join();
|
||||
var product = new ArmorProduct(GearSlot.BOOTS, GearTier.LEATHER);
|
||||
|
||||
var outcome = market.purchase(character.id(), character.revision(), catalog.revision(), product)
|
||||
.toCompletableFuture().join();
|
||||
|
||||
assertEquals(ArmorMarket.PurchaseStatus.APPLIED, outcome.status());
|
||||
assertEquals(java.math.BigInteger.valueOf(98), outcome.record().orElseThrow().coins());
|
||||
assertEquals(GearTier.LEATHER, outcome.record().orElseThrow().gear().boots());
|
||||
assertEquals(24, outcome.catalog().offer(product).remaining());
|
||||
assertTrue(outcome.catalog().transactions().isEmpty());
|
||||
} finally {
|
||||
settings.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupAfterMissedDaysGeneratesOnlyTodaysReplacement() throws Exception {
|
||||
var settings=new GearSettings(data,Map.of());var clock=new MutableClock(Instant.parse("2026-09-21T12:00:00Z"));
|
||||
UUID firstRevision;
|
||||
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);var market=new ArmorMarket(
|
||||
new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,
|
||||
(minimum,maximum)->minimum)){firstRevision=market.current().toCompletableFuture().join().revision();}
|
||||
clock.set(Instant.parse("2026-09-24T08:00:00Z"));
|
||||
try(var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);var market=new ArmorMarket(
|
||||
new YamlArmorCatalogRepository(data),new ArmorPurchaseOperationStore(data),characters,settings::rules,clock,
|
||||
(minimum,maximum)->maximum)){
|
||||
var current=market.current().toCompletableFuture().join();
|
||||
assertEquals(java.time.LocalDate.of(2026,9,24),current.day());
|
||||
assertEquals(ArmorCatalog.RefreshReason.STARTUP,current.reason());
|
||||
assertNotEquals(firstRevision,current.revision());
|
||||
assertEquals(18,current.products().size());
|
||||
}finally{settings.close();}
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPersistsOneCurrentUtcCatalogAndRestartDoesNotRerollOrRefillIt() throws Exception {
|
||||
var settings = new GearSettings(data, Map.of());
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"), ZoneOffset.UTC);
|
||||
ArmorCatalog first;
|
||||
try (var characters = new CharacterStorage(new YamlCharacterRepository(data), clock);
|
||||
var market = new ArmorMarket(new YamlArmorCatalogRepository(data),
|
||||
new ArmorPurchaseOperationStore(data), characters, settings::rules, clock,
|
||||
(minimum, maximum) -> minimum)) {
|
||||
first = market.current().toCompletableFuture().join();
|
||||
assertEquals(java.time.LocalDate.of(2026, 9, 21), first.day());
|
||||
assertEquals(ArmorCatalog.RefreshReason.STARTUP, first.reason());
|
||||
}
|
||||
try (var characters = new CharacterStorage(new YamlCharacterRepository(data), clock);
|
||||
var market = new ArmorMarket(new YamlArmorCatalogRepository(data),
|
||||
new ArmorPurchaseOperationStore(data), characters, settings::rules, clock,
|
||||
(minimum, maximum) -> maximum)) {
|
||||
assertEquals(first, market.current().toCompletableFuture().join());
|
||||
} finally {
|
||||
settings.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user