Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23179c62ae | ||
|
|
f0dc304f60 | ||
|
|
3444c786c3 | ||
|
|
3ba3ef4a0b | ||
|
|
bc1a0b1f29 | ||
|
|
fa219d9313 | ||
|
|
d98a2a80a3 |
No files matched your search
@@ -119,6 +119,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestDirectory adventureQuests;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestController adventureQuestController;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
@@ -170,6 +175,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
coinConfiguration.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.CoinSettings.defaultValue(key)));
|
||||
}
|
||||
var adventureQuestConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.AdventureQuestSettings.keys()) {
|
||||
adventureQuestConfiguration.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.parse(adventureQuestConfiguration);
|
||||
var recoveryConfiguration = outOfCombatRecoveryConfiguration();
|
||||
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
|
||||
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
|
||||
@@ -186,7 +197,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
|
||||
var rules = progressionRules();
|
||||
var rewardRules = combatRewardRules();
|
||||
var hardMobXpConfiguration = getConfig().getString(
|
||||
games.dmg.fantasystick.persistence.HardMobXpSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT);
|
||||
var hardMobXp = games.dmg.fantasystick.persistence.HardMobXpSettings.parse(hardMobXpConfiguration);
|
||||
var rewardRules = combatRewardRules(hardMobXp);
|
||||
var fireballConfiguration = fireballConfiguration();
|
||||
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
@@ -230,6 +245,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getDataFolder().toPath(), partyEconomyConfiguration);
|
||||
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
|
||||
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
|
||||
hardMobXpSettings = new games.dmg.fantasystick.persistence.HardMobXpSettings(
|
||||
getDataFolder().toPath(), hardMobXp.toString());
|
||||
adventureQuestSettings = new games.dmg.fantasystick.persistence.AdventureQuestSettings(
|
||||
getDataFolder().toPath(), adventureQuestConfiguration);
|
||||
var factory = new CharacterFactory(coinSettings::rules);
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC(),
|
||||
coinSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
@@ -290,9 +309,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
|
||||
trainingDummies = new games.dmg.fantasystick.training.TrainingDummyDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(getDataFolder().toPath()));
|
||||
adventureQuests = new games.dmg.fantasystick.adventure.AdventureQuestDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath()),
|
||||
Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
|
||||
} catch (java.io.IOException invalidManagedState) {
|
||||
throw new java.io.UncheckedIOException("Persistent party, shop or training state could not be loaded", invalidManagedState);
|
||||
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
|
||||
}
|
||||
characterParties.suspend();
|
||||
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
|
||||
@@ -458,6 +480,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
passiveRegistry.register(healthSteal.definition());
|
||||
passiveRegistry.register(speedI.definition());
|
||||
passiveRegistry.register(lowLightVision.definition());
|
||||
passiveRegistry.register(new games.dmg.fantasystick.passive.StickKnockback().definition());
|
||||
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
|
||||
+ "; passive " + problem.passive(), problem.cause()));
|
||||
@@ -515,8 +538,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if(lifetime==generation)localArmorMenus.refreshOpen();});}
|
||||
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
|
||||
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
|
||||
adventureQuestController = new games.dmg.fantasystick.adventure.AdventureQuestController(this,
|
||||
adventureQuests, adventureQuestSettings, sessions, parties, characters, rules, nextTick,
|
||||
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault());
|
||||
adventureQuestCommands = new games.dmg.fantasystick.adventure.AdventureQuestCommands(
|
||||
this, adventureQuests, adventureQuestController, nextTick);
|
||||
combatExperience.attachExcluded(adventureQuestController::managed);
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity));
|
||||
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
@@ -549,6 +578,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachArmorers(armorers,armorerController);
|
||||
admin.attachArmorMarket(armorMarket);
|
||||
admin.attachCoinSettings(coinSettings);
|
||||
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
|
||||
admin.attachAdventureQuests(adventureQuestCommands, adventureQuestSettings);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
|
||||
@@ -577,9 +608,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
|
||||
getServer().getPluginManager().registerEvents(armorerController,this);
|
||||
getServer().getPluginManager().registerEvents(armorShopMenus,this);
|
||||
getServer().getPluginManager().registerEvents(adventureQuestController,this);
|
||||
partyRegistrarController.install();
|
||||
trainingDummyController.install();
|
||||
armorerController.install();
|
||||
adventureQuestController.install();
|
||||
var localArmorMarket=armorMarket;
|
||||
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
|
||||
localArmorMarket.current().whenComplete((catalog,failure)->{
|
||||
@@ -655,6 +688,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingPartyRegistrarController = partyRegistrarController; partyRegistrarController = null;
|
||||
var closingTrainingDummies = trainingDummies; trainingDummies = null;
|
||||
var closingTrainingDummyController = trainingDummyController; trainingDummyController = null;
|
||||
var closingAdventureQuests = adventureQuests; adventureQuests = null;
|
||||
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
|
||||
adventureQuestCommands = null;
|
||||
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
|
||||
var closingPartyManagement = partyManagement; partyManagement = null;
|
||||
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
|
||||
@@ -716,6 +752,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null;
|
||||
var closingGearSettings=gearSettings;gearSettings=null;
|
||||
var closingCoinSettings = coinSettings; coinSettings = null;
|
||||
var closingHardMobXpSettings = hardMobXpSettings; hardMobXpSettings = null;
|
||||
var closingAdventureQuestSettings = adventureQuestSettings; adventureQuestSettings = null;
|
||||
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
|
||||
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
|
||||
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
|
||||
@@ -780,6 +818,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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 (closingHardMobXpSettings != null) cleanup("hard-mob XP configuration", closingHardMobXpSettings::close);
|
||||
if (closingAdventureQuestSettings != null) cleanup("adventure quest configuration", closingAdventureQuestSettings::close);
|
||||
if (closingAdventureQuests != null) cleanup("adventure quest persistence", closingAdventureQuests::close);
|
||||
if (closingAdventureQuestController != null) cleanup("adventure quests", closingAdventureQuestController::close);
|
||||
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
|
||||
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
|
||||
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
|
||||
@@ -802,6 +844,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
|
||||
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyEconomySettings.drained().toCompletableFuture(),
|
||||
closingHardMobXpSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHardMobXpSettings.drained().toCompletableFuture(),
|
||||
closingAdventureQuestSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdventureQuestSettings.drained().toCompletableFuture(),
|
||||
closingAdventureQuests == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdventureQuests.drained().toCompletableFuture(),
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -910,6 +958,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingTrainingDummyController);
|
||||
unregister(closingPartyRegistrarMenus);
|
||||
unregister(closingArmorerController);unregister(closingArmorShopMenus);
|
||||
unregister(closingAdventureQuestController);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingHealthSteal);
|
||||
unregister(closingFireball);
|
||||
@@ -969,13 +1018,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules(java.math.BigInteger hard) {
|
||||
try {
|
||||
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
|
||||
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
|
||||
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
|
||||
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, hard, boss, spacing);
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Registered command adapter for player inspection and acknowledged adventure administration. */
|
||||
public final class AdventureQuestCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;
|
||||
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);}
|
||||
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick adventure");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll replaces today's offers only when no quest or encounter is active. Run /fantasystick adventure reroll confirm to continue.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [reroll confirm]");return;}if(!authorized(sender))return;if(controller.hasActiveEncounter()){sender.sendMessage("Adventure reroll refused: an encounter is active.");return;}var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reroll failed; the current catalog is unchanged.");return;}switch(result.status()){case ACTIVE->sender.sendMessage("Adventure reroll refused: an accepted quest is active.");case STALE->sender.sendMessage("Adventure reroll refused: the catalog changed; inspect it and try again.");case APPLIED->{controller.catalogRerolled();var catalog=result.catalog().orElseThrow();sender.sendMessage("Rerolled daily adventures: revision="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}}}));}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [name...]|remove <site-uuid>|list>");return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().isEmpty())sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" world="+v.world()+" x="+v.x()+" y="+v.y()+" z="+v.z()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}String name=join(args,3);var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+").":"Adventure site save failed; desired state is unchanged.")));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}default->usageSite(sender);}}
|
||||
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" world="+v.world()+" x="+v.x()+" y="+v.y()+" z="+v.z()));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+").");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==2&&args[0].equals("adventure"))return matching(List.of("reroll"),args[1]);if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("remove"))return matching(directory.snapshot().sites().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
|
||||
private static String join(String[] args,int start){return start>=args.length?"":String.join(" ",Arrays.copyOfRange(args,start,args.length));}
|
||||
private static UUID parse(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(v->v.startsWith(prefix)).distinct().sorted().toList();}
|
||||
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [name...]|remove <site-uuid>|list>");}
|
||||
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fantasystick quest-giver <place [name...]|remove <giver-uuid>|list>");}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.RewardAllocator;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.boss.*;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.entity.PlayerLeashEntityEvent;
|
||||
import org.bukkit.inventory.*;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Native Quest Givers, shared camp/champion attempts, eligibility containment and idempotent completion rewards. */
|
||||
public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private static final String GIVER="quest_giver",ENEMY="adventure_enemy",PROJECTILE="adventure_projectile";
|
||||
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestSettings settings;
|
||||
private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
|
||||
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
|
||||
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey;
|
||||
private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Map<UUID,Attempt> attempts=new HashMap<>();private final Map<UUID,Instant> readyAt=new HashMap<>();private final Set<UUID> starting=new HashSet<>();
|
||||
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
|
||||
private static final class MenuHolder implements InventoryHolder {final UUID catalog;MenuHolder(UUID catalog){this.catalog=catalog;}@Override public Inventory getInventory(){return null;}}
|
||||
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final int radius,resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,int radius,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.radius=radius;this.resetSeconds=resetSeconds;}}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");}
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);}
|
||||
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();return enemy(entity)||data!=null&&PROJECTILE.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&§ion(giver.x())==chunk.getX()&§ion(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();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();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
|
||||
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
|
||||
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);}
|
||||
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
|
||||
public void catalogRerolled(){if(hasActiveEncounter())throw new IllegalStateException("Active adventure encounter");readyAt.clear();}
|
||||
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
|
||||
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);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.displayName()));villager.setCustomNameVisible(true);}
|
||||
public void open(Player player){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{if(failure!=null||context(player)==null){player.sendMessage("Daily quests are unavailable.");return;}var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision()),Math.max(9,Math.min(54,((catalog.sites().size()+8)/9)*9)),net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(site==null?Material.BARRIER:site.type()==AdventureQuestState.SiteType.CAMP?Material.CAMPFIRE:Material.NETHERITE_SWORD);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);String state=site==null?"Unavailable":active!=null&&active.site().equals(siteId)?"Active — click to abandon":active!=null?"Another quest is active":directory.claimed(catalog.revision(),siteId,context.character,context.partyId)?"Completed / already claimed":"Available — click to accept";var rules=settings.rules();String kind=site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?"Champion":"Monster camp";var rewardXp=active!=null&&active.site().equals(siteId)?active.rewardXp():site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?rules.championRewardXp():rules.campRewardXp();var rewardCoins=active!=null&&active.site().equals(siteId)?active.rewardCoins():site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?rules.championRewardCoins():rules.campRewardCoins();meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text("Reward: "+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}player.openInventory(inventory);}));}
|
||||
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
|
||||
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null&&directory.findGiver(id).isPresent())open(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var location=directory.findSite(site).orElseThrow();player.sendMessage("Accepted "+location.displayName()+" at "+location.world()+" "+location.x()+", "+location.y()+", "+location.z()+"; expires "+result.quest().orElseThrow().expiresAt()+".");}));}
|
||||
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+" at "+site.world()+" "+site.x()+", "+site.y()+", "+site.z()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+".");}
|
||||
private void pulse(){if(closed)return;directory.ensureToday();directory.expireParties(parties::exists);for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.radius).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
|
||||
private void contain(Attempt attempt){var site=directory.findSite(attempt.site).orElse(null);if(site==null)return;var world=plugin.getServer().getWorld(site.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity))&&!inside(entity.getLocation(),site,attempt.radius)){relocating.add(entity);try{entity.teleport(new Location(world,site.x(),site.y(),site.z()));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site){return eligibleInside(site,settings.rules().radius());}
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site,int radius){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,radius));}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
|
||||
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),rules.radius(),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
|
||||
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),rules.radius(),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
|
||||
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
|
||||
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!enemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);if(player==null)return;var quest=eligible(player,site(event.getEntity()));if(quest!=null){var attempt=attempts.get(quest.site());if(attempt!=null){attempt.represented.add(quest.id());if(attempt.champion==event.getEntity()&&attempt.bossBar!=null){double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,(attempt.champion.getHealth()-event.getFinalDamage())/maximum)));}}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){if(!enemy(event.getEntity()))return;event.getDrops().clear();event.setDroppedExp(0);UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);if(attempt==null)return;attempt.entities.remove(event.getEntity().getUniqueId());if(attempt.entities.isEmpty())clear(attempt);}
|
||||
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.radius);if(!recipients.isEmpty())award(quest,recipients);} }
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){return recipients(quest,site,settings.rules().radius());}
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,int radius){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,radius))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
private void award(AdventureQuestState.Quest quest,List<UUID> recipients){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var work=recipients.stream().map(character->awardOne(quest,character,xp.get(character),coins.get(character))).toArray(CompletableFuture[]::new);CompletableFuture.allOf(work).whenComplete((ignored,failure)->{if(failure==null)directory.complete(quest.id(),Set.copyOf(recipients));else plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward failed for quest "+quest.id(),failure);});}
|
||||
private java.util.concurrent.CompletableFuture<Void> awardOne(AdventureQuestState.Quest quest,UUID character,BigInteger xp,BigInteger coins){var result=new java.util.concurrent.CompletableFuture<Void>();characters.find(character).whenComplete((found,failure)->{if(failure!=null||found.isEmpty()){result.completeExceptionally(failure==null?new IllegalStateException("Character unavailable"):failure);return;}attemptAward(quest,found.orElseThrow(),xp,coins,result);});return result;}
|
||||
private void attemptAward(AdventureQuestState.Quest quest,games.dmg.fantasystick.persistence.CharacterRecord record,BigInteger xp,BigInteger coins,java.util.concurrent.CompletableFuture<Void> result){UUID receipt=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.unit()+":"+record.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));characters.awardCombatReward(record.id(),record.revision(),receipt,xp,coins,progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){result.completeExceptionally(failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptAward(quest,award.record().orElseThrow(),xp,coins,result);return;}if(award.record().isEmpty()){result.completeExceptionally(new IllegalStateException("Reward unavailable"));return;}var saved=award.record().orElseThrow();schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)player.sendMessage("Adventure complete: earned "+xp+" fantasy XP and "+coins+" character coins.");result.complete(null);});});}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(org.bukkit.event.entity.PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID site=site(event.getPotion());for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||site==null||eligible(player,site)==null)event.setIntensity(affected,0);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(enemy(event.getEntity())&&event.getTarget()!=null){UUID site=site(event.getEntity());if(!(event.getTarget() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(org.bukkit.event.entity.ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&enemy(shooter)){event.getEntity().getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,PROJECTILE);event.getEntity().getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,Objects.requireNonNull(site(shooter)).toString());}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(EntityCombustEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(EntityTransformEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void portal(EntityPortalEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void pickup(EntityPickupItemEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void block(EntityChangeBlockEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(EntityBreedEvent event){if(giver(event.getMother())||giver(event.getFather()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(giver(event.getEntered())||managed(event.getEntered()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(PlayerLeashEntityEvent event){if(giver(event.getEntity())||managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if((giver(event.getEntity())||managed(event.getEntity()))&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt);readyAt.put(site,clock.instant());}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void giverDamage(EntityDamageEvent event){if(giver(event.getEntity()))event.setCancelled(true);}
|
||||
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof org.bukkit.entity.Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
|
||||
private UUID site(Entity entity){return parse(entity.getPersistentDataContainer().get(siteKey,PersistentDataType.STRING));}
|
||||
private void syncBossBar(Attempt attempt){if(attempt.bossBar==null||attempt.champion==null)return;var viewers=eligibleInside(attempt.site,attempt.radius).collect(java.util.stream.Collectors.toSet());for(var player:plugin.getServer().getOnlinePlayers())if(viewers.contains(player))attempt.bossBar.addPlayer(player);else attempt.bossBar.removePlayer(player);double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,attempt.champion.getHealth()/maximum)));}
|
||||
private void cleanup(Attempt attempt){if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
private static boolean inside(Location location,AdventureQuestState.Site site,int radius){return location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&location.distanceSquared(new Location(location.getWorld(),site.x(),site.y(),site.z()))<=radius*(double)radius;}
|
||||
private Location location(AdventureQuestState.Giver value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
private static UUID parse(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static UUID id(Entity entity,NamespacedKey key){return parse(entity.getPersistentDataContainer().get(key,PersistentDataType.STRING));}
|
||||
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
|
||||
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.io.IOException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Serialized durable daily catalog, acceptance, roster and completion authority. */
|
||||
public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, CONFLICT, CLAIMED }
|
||||
public enum RerollStatus { APPLIED, ACTIVE, STALE }
|
||||
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
|
||||
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
|
||||
private final YamlAdventureQuestRepository repository;private final Clock clock;private final Supplier<AdventureQuestSettings.Rules> settings;private final java.util.random.RandomGenerator random;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-persistence"));
|
||||
private volatile AdventureQuestState snapshot;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public AdventureQuestDirectory(YamlAdventureQuestRepository repository,Clock clock,Supplier<AdventureQuestSettings.Rules> settings,java.util.random.RandomGenerator random)throws IOException{this.repository=Objects.requireNonNull(repository);this.clock=Objects.requireNonNull(clock);this.settings=Objects.requireNonNull(settings);this.random=Objects.requireNonNull(random);snapshot=repository.load();}
|
||||
public AdventureQuestState snapshot(){return snapshot;}
|
||||
public Optional<AdventureQuestState.Site> findSite(UUID id){return snapshot.sites().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public Optional<AdventureQuestState.Giver> findGiver(UUID id){return snapshot.givers().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
|
||||
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Giver>> removeGiver(UUID id){return mutate(state->{var found=state.givers().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());return result(copy(state,state.sites(),state.givers().stream().filter(v->!v.id().equals(id)).toList(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
|
||||
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var catalog=roll(state,today,state.catalog()==null?Set.of():Set.copyOf(state.catalog().sites()));var next=copy(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of());return result(next,catalog);});}
|
||||
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
|
||||
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));if(!state.quests().isEmpty())return result(state,new RerollResult(RerollStatus.ACTIVE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&¤t.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var next=copy(state,state.sites(),state.givers(),catalog,List.of(),sameDay?state.unitClaims():Set.of(),sameDay?state.characterClaims():Set.of(),Map.of());return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||find(state.sites(),site).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var rules=settings.get();var siteType=find(state.sites(),site).orElseThrow().type();var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),siteType==AdventureQuestState.SiteType.CAMP?rules.campRewardXp():rules.championRewardXp(),siteType==AdventureQuestState.SiteType.CAMP?rules.campRewardCoins():rules.championRewardCoins());var quests=new ArrayList<>(state.quests());quests.add(quest);return result(copy(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters()),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
|
||||
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
|
||||
public CompletableFuture<Boolean> complete(UUID questId,Set<UUID> recipients){return mutate(state->{var quest=state.quests().stream().filter(q->q.id().equals(questId)).findFirst().orElse(null);if(quest==null)return result(state,false);var units=new HashSet<>(state.unitClaims());units.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));var characters=new HashSet<>(state.characterClaims());for(UUID recipient:recipients)characters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),recipient));return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(questId)).toList(),units,characters,state.rosters()),true);});}
|
||||
public List<AdventureQuestState.Quest> active(){return expireView(snapshot).quests();}
|
||||
public boolean characterClaimed(UUID catalog,UUID site,UUID character){return snapshot.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(character));}
|
||||
public boolean claimed(UUID catalog,UUID site,UUID character,UUID party){var state=snapshot;return state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(character))||(party!=null&&state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==AdventureQuestState.UnitType.PARTY&&claim.unit().equals(party)));}
|
||||
public Optional<AdventureQuestState.Quest> activeFor(UUID character,UUID party){return active().stream().filter(q->q.unitType()==AdventureQuestState.UnitType.SOLO?q.unit().equals(character):party!=null&&q.unit().equals(party)&&q.snapshot().contains(character)).findFirst();}
|
||||
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate(state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
private AdventureQuestState expire(AdventureQuestState state){var view=expireView(state);return view.equals(state)?state:view;}
|
||||
private AdventureQuestState expireView(AdventureQuestState state){if(state.catalog()==null||clock.instant().isBefore(state.catalog().expiresAt()))return state;return copy(state,state.sites(),state.givers(),state.catalog(),List.of(),state.unitClaims(),state.characterClaims(),state.rosters());}
|
||||
private AdventureQuestState.Catalog roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var alternatives=state.sites().stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=state.sites().stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);int count=Math.min(settings.get().dailySiteCount(),state.sites().size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected);}
|
||||
private void shuffle(List<UUID> values){for(int i=values.size()-1;i>0;i--){int j=random.nextInt(i+1);var v=values.get(i);values.set(i,values.get(j));values.set(j,v);}}
|
||||
private static Optional<AdventureQuestState.Site> find(List<AdventureQuestState.Site> sites,UUID id){return sites.stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
private static AdventureQuestState copy(AdventureQuestState ignored,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters);}
|
||||
private record Result<T>(AdventureQuestState state,T value){}
|
||||
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value);}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
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,46 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Immutable durable authority for registered daily-adventure locations, offers, quests and claims. */
|
||||
public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,
|
||||
Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,
|
||||
Map<UUID,List<EntityType>> rosters) {
|
||||
public enum SiteType { CAMP, CHAMPION }
|
||||
public enum UnitType { SOLO, PARTY }
|
||||
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z) {
|
||||
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z); }
|
||||
public String displayName(){return name.isBlank()?type==SiteType.CAMP?"Monster Camp":"Champion Site":name;}
|
||||
}
|
||||
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
|
||||
public Giver { Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);if(!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid facing"); }
|
||||
public String displayName(){return name.isBlank()?"Quest Giver":name;}
|
||||
}
|
||||
public record Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites) {
|
||||
public Catalog { Objects.requireNonNull(day);Objects.requireNonNull(revision);Objects.requireNonNull(createdAt);sites=List.copyOf(sites);if(new HashSet<>(sites).size()!=sites.size())throw new IllegalArgumentException("Duplicate offer"); }
|
||||
public Instant expiresAt(){return day.plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant();}
|
||||
}
|
||||
public record Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,
|
||||
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins) {
|
||||
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit))))throw new IllegalArgumentException("Invalid quest"); }
|
||||
}
|
||||
public record UnitClaim(UUID catalog,UUID site,UnitType type,UUID unit) {
|
||||
public UnitClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(type);Objects.requireNonNull(unit);}
|
||||
}
|
||||
public record CharacterClaim(UUID catalog,UUID site,UUID character) {
|
||||
public CharacterClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}
|
||||
}
|
||||
public AdventureQuestState {
|
||||
sites=List.copyOf(sites);givers=List.copyOf(givers);quests=List.copyOf(quests);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);
|
||||
var copied=new LinkedHashMap<UUID,List<EntityType>>();rosters.forEach((key,value)->copied.put(key,List.copyOf(value)));rosters=Map.copyOf(copied);
|
||||
unique(sites.stream().map(Site::id).toList());unique(givers.stream().map(Giver::id).toList());unique(quests.stream().map(Quest::id).toList());
|
||||
}
|
||||
public static AdventureQuestState empty(){return new AdventureQuestState(List.of(),List.of(),null,List.of(),Set.of(),Set.of(),Map.of());}
|
||||
private static String validName(String name){Objects.requireNonNull(name);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(c->{int t=Character.getType(c);return t==Character.CONTROL||t==Character.FORMAT||t==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid name");return name;}
|
||||
private static void finite(double... values){for(double value:values)if(!Double.isFinite(value))throw new IllegalArgumentException("Invalid position");}
|
||||
private static void unique(List<UUID> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate identity");}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.IntUnaryOperator;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Fixed weighted monster-camp roster generation with reviewed guarantees and caps. */
|
||||
public final class CampRoster {
|
||||
private CampRoster() { }
|
||||
|
||||
public static List<EntityType> roll(int count, java.util.random.RandomGenerator random) {
|
||||
Objects.requireNonNull(random);
|
||||
return roll(count, () -> random.nextInt(100), random::nextInt);
|
||||
}
|
||||
|
||||
static List<EntityType> roll(int count, IntSupplier percent, IntUnaryOperator shuffleIndex) {
|
||||
Objects.requireNonNull(percent); Objects.requireNonNull(shuffleIndex);
|
||||
if (count < 4 || count > 64) throw new IllegalArgumentException("Camp enemy count must be 4 through 64");
|
||||
int guaranteed = (count + 1) / 2;
|
||||
int pillagerCap = count / 2;
|
||||
int vindicatorCap = Math.max(1, count / 4);
|
||||
int pillagers = 0, vindicators = 0, witches = 0;
|
||||
var result = new ArrayList<EntityType>(count);
|
||||
for (int index = 0; index < guaranteed; index++) result.add(EntityType.HUSK);
|
||||
while (result.size() < count) {
|
||||
int roll = percent.getAsInt();
|
||||
if (roll < 0 || roll >= 100) throw new IllegalArgumentException("Camp roll must be 0 through 99");
|
||||
EntityType selected;
|
||||
if (roll < 40) selected = EntityType.HUSK;
|
||||
else if (roll < 75 && pillagers < pillagerCap) { selected = EntityType.PILLAGER; pillagers++; }
|
||||
else if (roll < 95 && roll >= 75 && vindicators < vindicatorCap) { selected = EntityType.VINDICATOR; vindicators++; }
|
||||
else if (roll >= 95 && witches < 1) { selected = EntityType.WITCH; witches++; }
|
||||
else selected = EntityType.HUSK;
|
||||
result.add(selected);
|
||||
}
|
||||
for (int index = result.size() - 1; index > 0; index--) {
|
||||
int other = shuffleIndex.applyAsInt(index + 1);
|
||||
if (other < 0 || other > index) throw new IllegalArgumentException("Invalid shuffle index");
|
||||
var value = result.get(index); result.set(index, result.get(other)); result.set(other, value);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,10 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
final ControlInput input;
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
final boolean knockback;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +73,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var attempt = new Pending(input, target, now);
|
||||
var attempt = new Pending(input, target, now,
|
||||
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
|
||||
pending = attempt;
|
||||
try {
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
|
||||
@@ -82,21 +84,27 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
});
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
if (attempt.accepted) {
|
||||
readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
if (attempt.knockback && !trainingDamage.target(target) && input.stillCurrent()
|
||||
&& sessions.current(input.session())) {
|
||||
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.ONE).max(BigInteger.ONE);
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
|
||||
@@ -93,6 +93,9 @@ public final class SessionControls {
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
meta.setMaxStackSize(1);
|
||||
if (slot == 1 && character.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID)) {
|
||||
meta.addEnchant(org.bukkit.enchantments.Enchantment.KNOCKBACK, 1, true);
|
||||
}
|
||||
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
|
||||
@@ -57,7 +57,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
private final CombatRewardRules rewards;
|
||||
private volatile CombatRewardRules rewards;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
@@ -73,6 +73,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
private org.bukkit.scheduler.BukkitTask retryTask;
|
||||
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
|
||||
private boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
@@ -81,12 +82,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
final BigInteger experience;
|
||||
final BigInteger coins;
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
final CombatRewardRules rewardRules;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
|
||||
CombatRewardRules.KillPosition restricted) {
|
||||
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
|
||||
this.id = id; this.character = character; this.experience = experience;
|
||||
this.coins = coins; this.restricted = restricted;
|
||||
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +115,11 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
|
||||
}
|
||||
|
||||
public void attachExcluded(java.util.function.Predicate<org.bukkit.entity.Entity> predicate) {
|
||||
if (closed) throw new IllegalStateException("Combat experience is closed");
|
||||
excluded = Objects.requireNonNull(predicate);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
|
||||
@@ -134,7 +141,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|
||||
if (closed || excluded.test(event.getEntity()) || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|
||||
|| event.getFinalDamage() <= 0) return;
|
||||
var origin = sources.source(event);
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
@@ -172,7 +179,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (closed || event instanceof PlayerDeathEvent) return;
|
||||
if (closed || event instanceof PlayerDeathEvent || excluded.test(event.getEntity())) return;
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null, BigInteger.ZERO);
|
||||
}
|
||||
|
||||
@@ -193,14 +200,16 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
BigInteger coinPool = defeatedCharacter == null
|
||||
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
|
||||
if (units.isEmpty()) return; // The defeat transition has already persisted any PvP balance loss.
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(rewards.pool(kind, level), units, random);
|
||||
var capturedRewards = rewards;
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
|
||||
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
|
||||
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
|
||||
for (UUID character : xp.keySet()) {
|
||||
UUID receipt = UUID.nameUUIDFromBytes((targetReward + ":" + character + ":" + now).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted);
|
||||
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,
|
||||
capturedRewards);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
}
|
||||
@@ -259,7 +268,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
return;
|
||||
}
|
||||
characters.awardCombatReward(operation.character, operation.expectedRevision, operation.id,
|
||||
operation.experience, operation.coins, progression, rewards, operation.restricted).whenComplete((award, failure) -> {
|
||||
operation.experience, operation.coins, progression, operation.rewardRules, operation.restricted).whenComplete((award, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
@@ -319,9 +328,17 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public void configure(CombatRewardRules updated) {
|
||||
if (closed) throw new IllegalStateException("Combat rewards are closed");
|
||||
rewards = Objects.requireNonNull(updated);
|
||||
}
|
||||
|
||||
public CombatRewardRules rules() { return rewards; }
|
||||
|
||||
public static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
|
||||
if (type == EntityType.WITCH) return CombatRewardRules.Target.HARD;
|
||||
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class PartyRoster implements AutoCloseable {
|
||||
private UUID character(UUID player){return sessions.active(player).filter(sessions::current).map(EquipmentSessions.Active::character).orElse(null);}
|
||||
private List<UUID> activePlayers(CharacterParty party){return party.members().stream().map(sessions::activeCharacter).flatMap(Optional::stream).filter(sessions::current).map(EquipmentSessions.Active::player).sorted().toList();}
|
||||
public Optional<UUID> activePlayer(UUID character){if(durable==null)return Optional.of(character);return sessions.activeCharacter(character).filter(sessions::current).map(EquipmentSessions.Active::player);}
|
||||
public boolean exists(UUID party){if(durable!=null)return durable.ready()&&durable.all().stream().anyMatch(value->value.id().equals(party));return membership.values().stream().anyMatch(value->value.id().equals(party));}
|
||||
private Optional<Party> project(CharacterParty party){var members=activePlayers(party);UUID leader=sessions.activeCharacter(party.leader()).filter(sessions::current).map(EquipmentSessions.Active::player).orElse(party.leader());return Optional.of(new Party(party.id(),leader,members,party.leader(),party.members().stream().sorted().toList()));}
|
||||
private Party managedBy(UUID leader,UUID party){var group=membership.get(leader);return group!=null&&group.id().equals(party)&&group.leader().equals(leader)&&connected(leader)?group:null;}
|
||||
private void removeMember(Party group,UUID player){var remaining=group.members().stream().filter(member->!member.equals(player)).toList();if(remaining.stream().noneMatch(this::connected)){remove(group);return;}membership.remove(player);departed.remove(player);var updated=new Party(group.id(),group.leader(),remaining);for(var member:remaining)membership.put(member,updated);}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fixed reviewed metadata and native level-I behavior for the basic-stick Knockback passive. */
|
||||
public final class StickKnockback {
|
||||
public static final String ID = "stick_knockback";
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Knockback", Map.of(Stat.STR, BigInteger.valueOf(12)), Set.of(),
|
||||
BigInteger.ONE, List.of("Your bound basic attack stick carries native Knockback I.",
|
||||
"Only positive accepted basic-stick damage can knock the target back."));
|
||||
}
|
||||
|
||||
public static void apply(org.bukkit.entity.Player source, org.bukkit.entity.LivingEntity target) {
|
||||
double radians = Math.toRadians(source.getLocation().getYaw());
|
||||
double directionX = Math.sin(radians);
|
||||
double directionZ = -Math.cos(radians);
|
||||
if (!Double.isFinite(directionX) || !Double.isFinite(directionZ)) return;
|
||||
boolean sprinting = source.isSprinting();
|
||||
target.knockback(sprinting ? 2 : 1, directionX, directionZ);
|
||||
if (sprinting) source.setSprinting(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
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 settings for later daily catalogs, camp and champion attempts. */
|
||||
public final class AdventureQuestSettings implements AutoCloseable {
|
||||
public static final String DAILY_COUNT="adventure-daily-site-count", RADIUS="adventure-site-radius",
|
||||
RESET_SECONDS="adventure-site-reset-seconds", CAMP_ENEMIES="adventure-camp-enemies",
|
||||
CAMP_REWARD_XP="adventure-camp-reward-xp", CAMP_REWARD_COINS="adventure-camp-reward-coins",
|
||||
CHAMPION_HEALTH="adventure-champion-health", CHAMPION_DAMAGE="adventure-champion-damage",
|
||||
CHAMPION_REWARD_XP="adventure-champion-reward-xp", CHAMPION_REWARD_COINS="adventure-champion-reward-coins";
|
||||
private static final Map<String,String> DEFAULTS=Map.ofEntries(
|
||||
Map.entry(DAILY_COUNT,"3"),Map.entry(RADIUS,"16"),Map.entry(RESET_SECONDS,"30"),Map.entry(CAMP_ENEMIES,"8"),
|
||||
Map.entry(CAMP_REWARD_XP,"100"),Map.entry(CAMP_REWARD_COINS,"20"),Map.entry(CHAMPION_HEALTH,"100"),
|
||||
Map.entry(CHAMPION_DAMAGE,"8"),Map.entry(CHAMPION_REWARD_XP,"150"),Map.entry(CHAMPION_REWARD_COINS,"40"));
|
||||
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
|
||||
public record Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins) { }
|
||||
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-adventure-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 AdventureQuestSettings(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))));
|
||||
parse(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 Rules rules(){return parse(effective);}
|
||||
public static Rules parse(Map<String,String> values){
|
||||
try{return new Rules(bounded(values.get(DAILY_COUNT),0,20),bounded(values.get(RADIUS),4,64),
|
||||
bounded(values.get(RESET_SECONDS),0,600),bounded(values.get(CAMP_ENEMIES),4,64),
|
||||
whole(values.get(CAMP_REWARD_XP)),whole(values.get(CAMP_REWARD_COINS)),
|
||||
bounded(values.get(CHAMPION_HEALTH),1,2048),bounded(values.get(CHAMPION_DAMAGE),1,2048),
|
||||
whole(values.get(CHAMPION_REWARD_XP)),whole(values.get(CHAMPION_REWARD_COINS)));}
|
||||
catch(RuntimeException invalid){throw new IllegalArgumentException("Invalid adventure quest settings",invalid);}
|
||||
}
|
||||
private static int bounded(String raw,int minimum,int maximum){var value=new BigInteger(raw);if(value.compareTo(BigInteger.valueOf(minimum))<0||value.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return value.intValueExact();}
|
||||
private static BigInteger whole(String raw){var value=new BigInteger(raw);if(value.signum()<0||value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return value;}
|
||||
public CompletionStage<Update> set(String key,String raw){
|
||||
if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown adventure setting");
|
||||
var preview=new LinkedHashMap<>(effective);preview.put(key,new BigInteger(raw).toString());var accepted=preview.get(key);parse(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);parse(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;parse(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
|
||||
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,132 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
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.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publication Witch hard-mob XP pool. */
|
||||
public final class HardMobXpSettings implements AutoCloseable {
|
||||
public static final String KEY = "hard-mob-xp";
|
||||
public static final String DEFAULT = "20";
|
||||
private static final BigInteger MAXIMUM = BigInteger.valueOf(Integer.MAX_VALUE);
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String value, BigInteger pool, long sequence) { }
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-hard-mob-xp-configuration"));
|
||||
private volatile String effective;
|
||||
private String persisted;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public HardMobXpSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
HardMobXpSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
|
||||
public static BigInteger parse(String raw) {
|
||||
try {
|
||||
var value = new BigInteger(raw);
|
||||
if (value.signum() < 0 || value.compareTo(MAXIMUM) > 0) throw new IllegalArgumentException();
|
||||
return value;
|
||||
} catch (RuntimeException invalid) {
|
||||
throw new IllegalArgumentException("Hard-mob XP must be a whole number from 0 through 2147483647", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String raw) { return parse(raw).toString(); }
|
||||
public String value() { return effective; }
|
||||
public BigInteger pool() { return parse(effective); }
|
||||
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
String accepted = canonical(raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(accepted); persisted = accepted;
|
||||
return new Update(accepted, parse(accepted), ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new CompletionException(failure instanceof IllegalArgumentException ? failure
|
||||
: new IOException("Hard-mob XP save failed; effective value unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
|
||||
private void save(String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Unsafe 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(MAX_BYTES); 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(MAX_BYTES + 1);
|
||||
}
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} 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,54 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Atomic single-file durable authority for daily adventure quests. */
|
||||
public final class YamlAdventureQuestRepository {
|
||||
private final Path file;
|
||||
public YamlAdventureQuestRepository(Path data){file=data.toAbsolutePath().resolve("adventure-quests.yml");}
|
||||
public AdventureQuestState load()throws IOException{
|
||||
if(Files.notExists(file))return AdventureQuestState.empty();
|
||||
try{
|
||||
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);if(number(root.get("schema-version"))!=1)throw new IllegalArgumentException();
|
||||
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z")));}
|
||||
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch")));}
|
||||
AdventureQuestState.Catalog catalog=null;if(root.get("catalog")!=null){var r=map(root.get("catalog"));catalog=new AdventureQuestState.Catalog(LocalDate.parse(text(r.get("day"))),uuid(r,"revision"),Instant.parse(text(r.get("created-at"))),list(r.get("sites")).stream().map(value->UUID.fromString(text(value))).toList());}
|
||||
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins")))));}
|
||||
var unitClaims=new HashSet<AdventureQuestState.UnitClaim>();for(Object item:list(root.get("unit-claims"))){var r=map(item);unitClaims.add(new AdventureQuestState.UnitClaim(uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit")));}
|
||||
var characterClaims=new HashSet<AdventureQuestState.CharacterClaim>();for(Object item:list(root.get("character-claims"))){var r=map(item);characterClaims.add(new AdventureQuestState.CharacterClaim(uuid(r,"catalog"),uuid(r,"site"),uuid(r,"character")));}
|
||||
var rosters=new LinkedHashMap<UUID,List<EntityType>>();for(Object item:list(root.get("rosters"))){var r=map(item);rosters.put(uuid(r,"site"),list(r.get("entities")).stream().map(value->EntityType.valueOf(text(value))).toList());}
|
||||
return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid adventure quest file",invalid);}
|
||||
}
|
||||
public void save(AdventureQuestState state)throws IOException{
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",1);
|
||||
root.put("sites",state.sites().stream().sorted(Comparator.comparing(AdventureQuestState.Site::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z())).toList());
|
||||
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(v->row("id",v.id(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z(),"yaw",v.yaw(),"pitch",v.pitch())).toList());
|
||||
if(state.catalog()!=null)root.put("catalog",row("day",state.catalog().day(),"revision",state.catalog().revision(),"created-at",state.catalog().createdAt(),"sites",state.catalog().sites()));else root.put("catalog",null);
|
||||
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins())).toList());
|
||||
root.put("unit-claims",state.unitClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.unit())).map(v->row("catalog",v.catalog(),"site",v.site(),"unit-type",v.type(),"unit",v.unit())).toList());
|
||||
root.put("character-claims",state.characterClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.character())).map(v->row("catalog",v.catalog(),"site",v.site(),"character",v.character())).toList());
|
||||
root.put("rosters",state.rosters().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e->row("site",e.getKey(),"entities",e.getValue())).toList());
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".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 adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}
|
||||
}
|
||||
private static Map<String,Object> row(Object...pairs){var row=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)row.put((String)pairs[i],encode(pairs[i+1]));return row;}
|
||||
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof Instant||value instanceof LocalDate||value instanceof BigInteger||value instanceof Double||value instanceof Float)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlAdventureQuestRepository::encode).toList();return value;}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(64);options.setCodePointLimit(4_194_304);return new Yaml(new SafeConstructor(options));}
|
||||
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
|
||||
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
|
||||
private static String text(Object value){if(value instanceof String result)return result;throw new IllegalArgumentException();}
|
||||
private static UUID uuid(Map<?,?> row,String key){return UUID.fromString(text(row.get(key)));}
|
||||
private static double decimal(Map<?,?> row,String key){return Double.parseDouble(text(row.get(key)));}
|
||||
private static int number(Object value){return Integer.parseInt(value.toString());}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
@@ -74,7 +76,8 @@ public final class CombatFeedback implements AbilityFeedback {
|
||||
}
|
||||
|
||||
private static void send(Player player, String message) {
|
||||
try { player.sendMessage(message); } catch (RuntimeException ignored) { }
|
||||
try { player.sendMessage(Component.text(message, NamedTextColor.GRAY)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private static boolean recipientEffect(AbilityReport.Type type) {
|
||||
|
||||
@@ -8,9 +8,9 @@ import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
|
||||
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
|
||||
BigDecimal restrictedSpacing) {
|
||||
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
|
||||
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger hardPool,
|
||||
BigInteger bossPool, BigDecimal restrictedSpacing) {
|
||||
public enum Target { PASSIVE, ORDINARY, HARD, BOSS, FANTASY_CHARACTER }
|
||||
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
|
||||
|
||||
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
|
||||
@@ -26,16 +26,26 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
|
||||
}
|
||||
|
||||
public CombatRewardRules {
|
||||
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
|
||||
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool); Objects.requireNonNull(hardPool);
|
||||
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
|
||||
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
|
||||
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || hardPool.signum() < 0 || bossPool.signum() < 0
|
||||
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
|
||||
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
|
||||
}
|
||||
}
|
||||
|
||||
public CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
|
||||
BigDecimal restrictedSpacing) {
|
||||
this(passivePool, ordinaryPool, BigInteger.valueOf(20), bossPool, restrictedSpacing);
|
||||
}
|
||||
|
||||
public static CombatRewardRules defaults() {
|
||||
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
|
||||
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(20),
|
||||
BigInteger.valueOf(100), BigDecimal.valueOf(3));
|
||||
}
|
||||
|
||||
public CombatRewardRules withHardPool(BigInteger pool) {
|
||||
return new CombatRewardRules(passivePool, ordinaryPool, pool, bossPool, restrictedSpacing);
|
||||
}
|
||||
|
||||
public BigInteger pool(Target target, int defeatedLevel) {
|
||||
@@ -47,6 +57,7 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
|
||||
return switch (target) {
|
||||
case PASSIVE -> passivePool;
|
||||
case ORDINARY -> ordinaryPool;
|
||||
case HARD -> hardPool;
|
||||
case BOSS -> bossPool;
|
||||
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
|
||||
};
|
||||
|
||||
@@ -69,6 +69,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
|
||||
private games.dmg.fantasystick.party.CombatExperience combatExperience;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
|
||||
@@ -316,6 +320,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
|
||||
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
|
||||
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
|
||||
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
|
||||
games.dmg.fantasystick.party.CombatExperience experience) {
|
||||
if (closed || hardMobXpSettings != null) throw new IllegalStateException("Hard-mob XP settings already attached or command closed");
|
||||
hardMobXpSettings = Objects.requireNonNull(settings); combatExperience = Objects.requireNonNull(experience);
|
||||
}
|
||||
public void attachAdventureQuests(games.dmg.fantasystick.adventure.AdventureQuestCommands commands,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings settings) {
|
||||
if (closed || adventureQuestCommands != null) throw new IllegalStateException("Adventure quests already attached or command closed");
|
||||
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = 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,
|
||||
OutOfCombatRecovery recovery) {
|
||||
@@ -386,10 +400,19 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure")) {
|
||||
adventureQuestCommands.adventure(sender, args); return true;
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
|
||||
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure-site")) {
|
||||
adventureQuestCommands.site(sender, args); return true;
|
||||
}
|
||||
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("quest-giver")) {
|
||||
adventureQuestCommands.giver(sender, args); return true;
|
||||
}
|
||||
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
|
||||
if (args.length > 0 && args[0].equals("training-dummy")) {
|
||||
trainingDummy(sender, args); return true;
|
||||
@@ -691,6 +714,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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 (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
|
||||
hardMobXpConfiguration(sender, args); return;
|
||||
}
|
||||
if (adventureQuestSettings != null && games.dmg.fantasystick.persistence.AdventureQuestSettings.keys().contains(args[1])) {
|
||||
adventureQuestConfiguration(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; }
|
||||
if (outOfCombatRecoverySettings != null
|
||||
@@ -1319,6 +1348,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
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 hardMobXpConfiguration(CommandSender sender, String[] args) {
|
||||
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + hardMobXpSettings.value()
|
||||
+ " (applies to future uncaptured Witch defeats only)"); return; }
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HardMobXpSettings.Update> operation;
|
||||
try { operation = hardMobXpSettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Hard-mob XP 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 && hardMobXpSettings.publish(saved);
|
||||
if (applied) combatExperience.configure(combatExperience.rules().withHardPool(saved.pool()));
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Hard-mob XP save failed; the effective pool is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "="
|
||||
+ hardMobXpSettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + key + "=" + saved.value()
|
||||
+ " for future uncaptured Witch defeats; existing progress and captured rewards are unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void adventureQuestConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1]; if(args.length==2){sender.sendMessage(key+"="+adventureQuestSettings.value(key)+" (applies to the owning later catalog, acceptance or encounter attempt)");return;}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdventureQuestSettings.Update> operation;
|
||||
try{operation=adventureQuestSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Adventure quest setting is invalid; the effective value is unchanged.");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&&adventureQuestSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+adventureQuestSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is 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);}});}
|
||||
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
@@ -1361,10 +1424,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (closed) return List.of();
|
||||
if (adventureQuestCommands != null) {
|
||||
if (args.length == 1 && !sender.hasPermission(PERMISSION)) return matching(List.of("adventure"), args[0]);
|
||||
if (args.length > 1) {
|
||||
var adventure = adventureQuestCommands.complete(sender,args); if(!adventure.isEmpty()) return adventure;
|
||||
if(args[0].equals("adventure")||args[0].equals("adventure-site")||args[0].equals("quest-giver"))return List.of();
|
||||
}
|
||||
}
|
||||
if (!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", "shop", "training-dummy")
|
||||
: List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"), args[0]);
|
||||
? List.of("adventure", "adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
|
||||
: List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), 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("training-dummy")) return matching(
|
||||
@@ -1387,6 +1458,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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.add(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.AdventureQuestSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
|
||||
@@ -1420,6 +1493,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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 (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
|
||||
String current = hardMobXpSettings.value(); String defaults = games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT;
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if(adventureQuestSettings!=null&&games.dmg.fantasystick.persistence.AdventureQuestSettings.keys().contains(args[1])){String current=adventureQuestSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.AdventureQuestSettings.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
|
||||
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
|
||||
|
||||
@@ -287,8 +287,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
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;
|
||||
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns())
|
||||
|| !previous.passives().equals(saved.passives())) && !refreshControls(attempt, session)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private boolean refreshControls(Attempt attempt, Active session) {
|
||||
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
|
||||
boolean changed = false;
|
||||
for (int slot = 2; slot < 9; slot++) {
|
||||
for (int slot = 1; slot < 9; slot++) {
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
|
||||
attempt.player.getInventory().setItem(slot, refreshed[slot]);
|
||||
|
||||
@@ -56,9 +56,22 @@ boss-coin-drop-max: 50
|
||||
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
|
||||
passive-xp: 1
|
||||
ordinary-xp: 10
|
||||
hard-mob-xp: 20
|
||||
boss-xp: 100
|
||||
restricted-kill-spacing-blocks: 3
|
||||
|
||||
# UTC-daily shared adventure catalogs, camp attempts and per-unit completion rewards.
|
||||
adventure-daily-site-count: 3
|
||||
adventure-site-radius: 16
|
||||
adventure-site-reset-seconds: 30
|
||||
adventure-camp-enemies: 8
|
||||
adventure-camp-reward-xp: 100
|
||||
adventure-camp-reward-coins: 20
|
||||
adventure-champion-health: 100
|
||||
adventure-champion-damage: 8
|
||||
adventure-champion-reward-xp: 150
|
||||
adventure-champion-reward-coins: 40
|
||||
|
||||
# Fireball defaults from the reviewed starter catalogue.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
fireball-base-damage: 6
|
||||
|
||||
@@ -7,9 +7,9 @@ description: Transferable characters, native fantasy health, recoverable equipme
|
||||
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] | shop <place [name...]|remove <shop-uuid>|list|restock> | party-registrar <place [name...]|remove <registrar-uuid>|list> | training-dummy <place [name...]|remove <dummy-uuid>|list>
|
||||
permission: fantasystick.admin
|
||||
description: Inspect daily adventures or administer Fantasy Stick.
|
||||
usage: /fantasystick adventure [reroll confirm] | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|list> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
|
||||
|
||||
fantasyfeedback:
|
||||
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
|
||||
usage: /fantasyfeedback [on|off]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import 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.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdventureQuestNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
@Test void registeredCommandsGiverMenuAcceptanceCampCombatAndExactRewardUseConnectedPath() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"adventure-daily-site-count: 3\nadventure-site-reset-seconds: 0\nadventure-camp-enemies: 4\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id, actor.player); doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;
|
||||
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
|
||||
var spawned=new ArrayList<LivingEntity>();
|
||||
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=type==Villager.class?mock(Villager.class):mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
|
||||
|
||||
assertEquals(List.of("adventure-camp-enemies","adventure-camp-reward-coins","adventure-camp-reward-xp"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-camp"}));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-site-radius","20"});drainOne(f);
|
||||
var questSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");assertEquals(20,questSettings.rules().radius());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","camp","Dawn Camp"}); drainOne(f);
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"}); drainOne(f);
|
||||
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
|
||||
assertEquals(1,directory.snapshot().sites().size());assertEquals(1,directory.snapshot().givers().size());
|
||||
assertEquals(List.of("camp","champion"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","c"}));
|
||||
var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);
|
||||
var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();verify(giver,atLeastOnce()).setAI(false);verify(giver,atLeastOnce()).setGravity(false);verify(giver,atLeastOnce()).setInvulnerable(true);verify(giver,atLeastOnce()).setCollidable(false);
|
||||
f.activate(actor);int books=actor.books.size();var interaction=new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND);f.dispatcher.accept(interaction);drainOne(f);assertTrue(interaction.isCancelled());assertEquals(books,actor.books.size());assertFalse(actor.inventories.isEmpty());int menus=actor.inventories.size();var duplicate=new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.OFF_HAND);f.dispatcher.accept(duplicate);assertTrue(duplicate.isCancelled());assertEquals(menus,actor.inventories.size());
|
||||
var menu=actor.inventories.getLast();assertFalse(menu.getItem(0).getItemMeta().lore().stream().map(Object::toString).anyMatch(line->line.contains("0.0")),"Coordinates stay hidden before acceptance");var originalCatalog=directory.snapshot().catalog();
|
||||
doReturn(false).when(actor.player).hasPermission("fantasystick.admin");f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});assertTrue(actor.messages.getLast().contains("Permission denied"));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure",""}).isEmpty());assertEquals(originalCatalog,directory.snapshot().catalog());doReturn(true).when(actor.player).hasPermission("fantasystick.admin");f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm","extra"});assertTrue(actor.messages.getLast().contains("Usage"));assertEquals(originalCatalog,directory.snapshot().catalog());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll"});assertTrue(actor.messages.getLast().contains("confirm"));assertEquals(originalCatalog,directory.snapshot().catalog());assertEquals(List.of("reroll"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","r"}));assertEquals(List.of("confirm"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","reroll","c"}));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});drainOne(f);assertNotEquals(originalCatalog.revision(),directory.snapshot().catalog().revision());assertTrue(actor.messages.getLast().contains("Rerolled")&&actor.messages.getLast().contains("expires"));
|
||||
var stale=mock(InventoryClickEvent.class);when(stale.getWhoClicked()).thenReturn(actor.player);when(stale.getInventory()).thenReturn(menu);when(stale.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(stale);drainOne(f);assertTrue(actor.messages.getLast().contains("stale"));
|
||||
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);menu=actor.inventories.getLast();var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);drainOne(f);
|
||||
assertTrue(actor.messages.stream().anyMatch(message->message.contains("Accepted")&&message.contains("Dawn Camp")));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure"});assertTrue(actor.messages.getLast().contains("Dawn Camp"));assertTrue(actor.messages.getLast().contains("0.0"));
|
||||
var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);partyA.core.location.setWorld(actor.world);partyB.core.location.setWorld(actor.world);f.players.put(partyA.core.id,partyA.player);f.players.put(partyB.core.id,partyB.player);f.activate(partyA);f.activate(partyB);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=directory.snapshot().catalog();var siteId=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),siteId,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var actorCoins=storage.find(actor.core.record.id()).get().orElseThrow().coins();var partyACoins=storage.find(partyA.core.record.id()).get().orElseThrow().coins();var partyBCoins=storage.find(partyB.core.record.id()).get().orElseThrow().coins();
|
||||
|
||||
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();drainOne(f);
|
||||
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());var activeCatalog=directory.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});assertEquals(activeCatalog,directory.snapshot().catalog());assertTrue(actor.messages.getLast().contains("active"));
|
||||
var enemy=enemies.getFirst();var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);var outsiderSource=mock(org.bukkit.damage.DamageSource.class);when(outsiderSource.getCausingEntity()).thenReturn(outsider.player);when(outsiderSource.getDirectEntity()).thenReturn(outsider.player);var refusedHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(outsider.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,outsiderSource,1);f.dispatcher.accept(refusedHit);assertTrue(refusedHit.isCancelled());var enemySource=mock(org.bukkit.damage.DamageSource.class);when(enemySource.getCausingEntity()).thenReturn(enemy);when(enemySource.getDirectEntity()).thenReturn(enemy);var refusedAttack=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,1);f.dispatcher.accept(refusedAttack);assertTrue(refusedAttack.isCancelled());var targetEvent=new org.bukkit.event.entity.EntityTargetLivingEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_PLAYER);f.dispatcher.accept(targetEvent);assertTrue(targetEvent.isCancelled());
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var attemptsField=controller.getClass().getDeclaredField("attempts");attemptsField.setAccessible(true);var attempts=(Map<?,?>)attemptsField.get(controller);assertEquals(1,attempts.size());var attempt=attempts.values().iterator().next();var representedField=attempt.getClass().getDeclaredField("represented");representedField.setAccessible(true);assertEquals(1,((Set<?>)representedField.get(attempt)).size());var recipientsMethod=controller.getClass().getDeclaredMethod("recipients",games.dmg.fantasystick.adventure.AdventureQuestState.Quest.class,UUID.class);recipientsMethod.setAccessible(true);var activeQuest=directory.active().getFirst();assertEquals(List.of(actor.core.record.id()),recipientsMethod.invoke(controller,activeQuest,activeQuest.site()));
|
||||
for(var value:enemies){var death=new org.bukkit.event.entity.EntityDeathEvent(value,source,new ArrayList<>(),7);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());}
|
||||
assertTrue(((Map<?,?>)attemptsField.get(controller)).isEmpty());
|
||||
for(int i=0;i<50&&(directory.active().size()!=1||storage.find(actor.core.record.id()).get().orElseThrow().experience().signum()==0);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
|
||||
var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),saved.experience());assertEquals(actorCoins.add(java.math.BigInteger.valueOf(20)),saved.coins());assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
|
||||
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedA=storage.find(partyA.core.record.id()).get().orElseThrow();var savedB=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),savedA.experience().add(savedB.experience()));assertTrue(savedA.experience().signum()>0&&savedB.experience().signum()>0,"Present non-attacker shares party reward");assertEquals(java.math.BigInteger.valueOf(20),savedA.coins().subtract(partyACoins).add(savedB.coins().subtract(partyBCoins)));assertTrue(directory.active().isEmpty());var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.CLAIMED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
}
|
||||
}
|
||||
|
||||
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ class BasicAttackNativeTest {
|
||||
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
@@ -35,6 +36,7 @@ class BasicAttackNativeTest {
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
event.setCancelled(cancelled.get());
|
||||
if (zero.get()) event.setDamage(0);
|
||||
basic.acceptedDamage(event);
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), same(a.player));
|
||||
@@ -43,12 +45,13 @@ class BasicAttackNativeTest {
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
|
||||
() -> sessions.current(active));
|
||||
basic.accept(input); // Cancelled: no cadence spent.
|
||||
cancelled.set(false);
|
||||
basic.accept(input); // Accepted at t=0.
|
||||
cancelled.set(false); zero.set(true);
|
||||
basic.accept(input); // Zero final damage: no cadence spent.
|
||||
zero.set(false); basic.accept(input); // Accepted at t=0.
|
||||
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
|
||||
verify(target, times(2)).damage(1.0, a.player);
|
||||
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
|
||||
verify(target, times(3)).damage(1.0, a.player);
|
||||
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
|
||||
verify(target, times(4)).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
|
||||
verifyNoMoreInteractions(target);
|
||||
@@ -88,6 +91,32 @@ class BasicAttackNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicStickUsesBasePlusStrengthModifierAcrossApprovedBoundaries() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
int[] strengths = {0, 9, 10, 11, 12, 13, 14, 15};
|
||||
double[] expected = {1, 1, 1, 1, 2, 2, 3, 3};
|
||||
for (int index = 0; index < strengths.length; index++) {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withStrength(data, actor, java.math.BigInteger.valueOf(strengths[index]));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled()); verify(target).damage(expected[index], actor.player);
|
||||
actor.core.inventory.setItemInOffHand(null); f.changed(actor); f.awaitCharacterAndReceipt(actor);
|
||||
}
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withStrength(data, actor, java.math.BigInteger.TEN.pow(1000));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
|
||||
verify(target).damage(Double.MAX_VALUE, actor.player);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -106,7 +135,20 @@ class BasicAttackNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
java.math.BigInteger strength) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, strength);
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
|
||||
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(),
|
||||
old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(),
|
||||
false, null, null, old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()),
|
||||
old.revision());
|
||||
}
|
||||
|
||||
static final class MutableClock extends Clock {
|
||||
private Instant now;
|
||||
MutableClock(Instant now) { this.now = now; }
|
||||
void advance(java.time.Duration duration) { now = now.plus(duration); }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChampionQuestNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
@Test void registeredChampionOfferNativeEncounterBossBarAndExactRewardUseConnectedPath() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"adventure-daily-site-count: 1\nadventure-site-reset-seconds: 0\n"+
|
||||
"adventure-champion-health: 100\nadventure-champion-damage: 8\n"+
|
||||
"adventure-champion-reward-xp: 150\nadventure-champion-reward-coins: 40\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
|
||||
var spawned=new ArrayList<LivingEntity>();var maxHealth=mock(AttributeInstance.class);when(maxHealth.getValue()).thenReturn(100.0);var attack=mock(AttributeInstance.class);
|
||||
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(entity.getHealth()).thenReturn(100.0);if(entity instanceof Vindicator){when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maxHealth);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(attack);}((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
|
||||
|
||||
assertEquals(List.of("adventure-champion-damage","adventure-champion-health","adventure-champion-reward-coins","adventure-champion-reward-xp"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-champion"}));
|
||||
assertEquals(List.of("champion"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","ch"}));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","9"});drainOne(f);assertEquals(List.of("8","9"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage",""}));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","Summit"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"});drainOne(f);
|
||||
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);drainOne(f);
|
||||
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);member.core.location.setX(0);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||storage.find(actor.core.record.id()).get().orElseThrow().experience().signum()==0);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(150),saved.experience().subtract(initial.experience()).add(savedMember.experience().subtract(memberInitial.experience())));assertEquals(java.math.BigInteger.valueOf(40),saved.coins().subtract(initial.coins()).add(savedMember.coins().subtract(memberInitial.coins())));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();
|
||||
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience().add(java.math.BigInteger.valueOf(150)),savedSolo.experience());assertEquals(soloInitial.coins().add(java.math.BigInteger.valueOf(40)),savedSolo.coins());verify(f.bossBars.get(1),atLeastOnce()).removeAll();
|
||||
}
|
||||
}
|
||||
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
|
||||
}
|
||||
@@ -32,6 +32,48 @@ class CombatExperienceNativeTest {
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(wither));
|
||||
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.ORDINARY,
|
||||
games.dmg.fantasystick.party.CombatExperience.classify(wither));
|
||||
var witch = type(EntityType.WITCH);
|
||||
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.HARD,
|
||||
games.dmg.fantasystick.party.CombatExperience.classify(witch));
|
||||
assertEquals(games.dmg.fantasystick.progression.CoinRules.Target.HOSTILE,
|
||||
games.dmg.fantasystick.party.CombatExperience.coinTarget(witch));
|
||||
}
|
||||
|
||||
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var solo = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
for (var actor : java.util.List.of(solo, a, b, c)) { f.players.put(actor.core.id, actor.player); f.activate(actor); }
|
||||
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
assertTrue(parties.accept(c.core.id, parties.invite(a.core.id, party.id(), c.core.id).orElseThrow().id()));
|
||||
var beforeCoins = record(f, solo).coins().add(record(f, a).coins())
|
||||
.add(record(f, b).coins()).add(record(f, c).coins());
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var witch = mob(EntityType.WITCH, new Location(world, 10, 64, 0));
|
||||
hit(f, solo.player, witch, false);
|
||||
var projectile = PartyCombatNativeTest.projectile(f, a.player); hit(f, projectile, witch, false);
|
||||
die(f, witch); settle(f); settle(f); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, solo).experience());
|
||||
var partyXp = java.util.List.of(record(f, a).experience(), record(f, b).experience(), record(f, c).experience());
|
||||
assertEquals(java.math.BigInteger.TEN,
|
||||
partyXp.stream().reduce(java.math.BigInteger.ZERO, java.math.BigInteger::add));
|
||||
assertEquals(java.util.List.of(java.math.BigInteger.valueOf(3), java.math.BigInteger.valueOf(3),
|
||||
java.math.BigInteger.valueOf(4)), partyXp.stream().sorted().toList());
|
||||
var afterCoins = record(f, solo).coins().add(record(f, a).coins())
|
||||
.add(record(f, b).coins()).add(record(f, c).coins());
|
||||
var delta = afterCoins.subtract(beforeCoins);
|
||||
assertTrue(delta.compareTo(java.math.BigInteger.ONE) >= 0 && delta.compareTo(java.math.BigInteger.valueOf(5)) <= 0,
|
||||
"Witch uses one ordinary hostile coin pool");
|
||||
assertTrue(solo.messages.stream().anyMatch(message -> message.contains("10") && message.contains("XP")));
|
||||
solo.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, solo);
|
||||
assertTrue(PartyBookNativeTest.text(solo).contains("XP: 10/100"));
|
||||
die(f, witch); settle(f);
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, solo).experience(), "Duplicate death callbacks are consumed once");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
|
||||
@@ -197,13 +239,13 @@ class CombatExperienceNativeTest {
|
||||
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
|
||||
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
|
||||
original.stats(), original.level(), java.math.BigInteger.valueOf(20), original.unspentPoints(), original.health(),
|
||||
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
|
||||
var success = java.util.concurrent.CompletableFuture.completedFuture(
|
||||
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
|
||||
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.TEN), any(),
|
||||
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.valueOf(20)), any(),
|
||||
same(progression), any(), any())).thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
|
||||
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
|
||||
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
|
||||
@@ -211,13 +253,14 @@ class CombatExperienceNativeTest {
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
|
||||
ignored -> attacker, failures::add);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
|
||||
var target = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
|
||||
var damageSource = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
|
||||
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
assertEquals(1, failures.size());
|
||||
service.configure(service.rules().withHardPool(java.math.BigInteger.valueOf(25)));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
@@ -261,6 +304,40 @@ class CombatExperienceNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void hardMobXpCommandPersistsBeforePublishingAndAffectsOnlyFutureWitchDefeats() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"hard-mob-xp: 25\nunrelated: retained\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = true;
|
||||
assertEquals(java.util.List.of("hard-mob-xp"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard"}));
|
||||
assertEquals(java.util.List.of("20", "25"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp"});
|
||||
assertTrue(sender.messages.getLast().contains("=25"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", "2147483648"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
var service = (games.dmg.fantasystick.party.CombatExperience)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience");
|
||||
assertEquals(java.math.BigInteger.valueOf(25), service.rules().hardPool());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", "30"});
|
||||
Runnable response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response);
|
||||
assertEquals(java.math.BigInteger.valueOf(25), service.rules().hardPool(), "Durable write precedes publication");
|
||||
response.run(); assertEquals(java.math.BigInteger.valueOf(30), service.rules().hardPool());
|
||||
var yaml = (java.util.Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(
|
||||
java.nio.file.Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated")); assertEquals("30", String.valueOf(yaml.get("hard-mob-xp")));
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var witch = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
|
||||
hit(f, actor.player, witch, false); die(f, witch); settle(f);
|
||||
assertEquals(java.math.BigInteger.valueOf(30), record(f, actor).experience());
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard"}).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
@@ -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","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("adventure-site","config","experience","give","inspect","party-registrar","quest-giver","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
|
||||
@@ -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", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
|
||||
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
|
||||
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
|
||||
@@ -109,7 +109,8 @@ class EquipmentAdminCommandNativeTest {
|
||||
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "t"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "true", ""}).isEmpty());
|
||||
sender.allowed = false;
|
||||
for (String[] args : List.of(new String[]{""}, new String[]{"inspect", ""}, new String[]{"config", ""},
|
||||
assertEquals(List.of("adventure"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
for (String[] args : List.of(new String[]{"inspect", ""}, new String[]{"config", ""},
|
||||
new String[]{"config", "adventure-mode", ""})) {
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", args).isEmpty());
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class FantasyFeedbackCommandNativeTest {
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(fixture.feedbackCommand.execute(player.player, "fantasyfeedback", new String[]{}));
|
||||
assertEquals("Fantasy feedback is off.", player.messages.getLast());
|
||||
assertTrue(player.messageComponents.isEmpty(), "preference/status notices keep their existing presentation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ class FantasyFeedbackCommandNativeTest {
|
||||
assertTrue(((games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences"))
|
||||
.enabled(player.core.id));
|
||||
assertTrue(player.messageComponents.isEmpty(), "refusals are not routine outcome components");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +77,10 @@ class FantasyFeedbackCommandNativeTest {
|
||||
String healing = "Mending Light healed " + target.core.record.name() + " for 1.23 health.";
|
||||
assertEquals(1, source.messages.stream().filter(healing::equals).count());
|
||||
assertEquals(1, target.messages.stream().filter(healing::equals).count());
|
||||
assertEquals(net.kyori.adventure.text.format.NamedTextColor.GRAY,
|
||||
source.messageComponents.getLast().color());
|
||||
assertEquals(net.kyori.adventure.text.format.NamedTextColor.GRAY,
|
||||
target.messageComponents.getLast().color());
|
||||
|
||||
fixture.feedbackCommand.execute(source.player, "fantasyfeedback", new String[]{"off"}); fixture.tick();
|
||||
feedback.report("Sanctuary", source.player,
|
||||
@@ -114,10 +120,19 @@ class FantasyFeedbackCommandNativeTest {
|
||||
assertTrue(source.messages.contains("Stone Skin prevented 1.01 damage to "
|
||||
+ target.core.record.name() + "."));
|
||||
assertTrue(source.messages.contains("Rallying Cry added 0.5 damage against Zombie."));
|
||||
assertEquals(6, source.messageComponents.size());
|
||||
assertEquals(2, target.messageComponents.size());
|
||||
assertTrue(source.messageComponents.stream().allMatch(component ->
|
||||
net.kyori.adventure.text.format.NamedTextColor.GRAY.equals(component.color())));
|
||||
assertTrue(target.messageComponents.stream().allMatch(component ->
|
||||
net.kyori.adventure.text.format.NamedTextColor.GRAY.equals(component.color())));
|
||||
assertTrue(source.messages.stream().noneMatch(message -> message.indexOf('\u00a7') >= 0));
|
||||
int messages = source.messages.size();
|
||||
int components = source.messageComponents.size();
|
||||
feedback.report("No-op", source.player,
|
||||
games.dmg.fantasystick.ability.AbilityReport.Type.DAMAGE, zombie, java.math.BigDecimal.ZERO);
|
||||
assertEquals(messages, source.messages.size());
|
||||
assertEquals(components, source.messageComponents.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(13, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor-market/menu refresh and session particles");
|
||||
assertEquals(14, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/adventure refresh and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
@@ -904,6 +904,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
|
||||
int scenario = 0;
|
||||
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
|
||||
"hard-mob-xp: -1", "hard-mob-xp: 2147483648", "hard-mob-xp: 1.5",
|
||||
"adventure-daily-site-count: 21", "adventure-site-radius: 3",
|
||||
"adventure-site-reset-seconds: 601", "adventure-camp-enemies: 3",
|
||||
"adventure-camp-reward-xp: -1", "adventure-camp-reward-coins: 2147483648",
|
||||
"adventure-champion-health: 0", "adventure-champion-health: 2049",
|
||||
"adventure-champion-damage: 0", "adventure-champion-damage: 2049",
|
||||
"adventure-champion-reward-xp: -1", "adventure-champion-reward-coins: 2147483648",
|
||||
"restricted-kill-spacing-blocks: -0.1", "restricted-kill-spacing-blocks: 1e1000000")) {
|
||||
var directory = data.resolve("invalid-reward-" + scenario++); java.nio.file.Files.createDirectories(directory);
|
||||
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
|
||||
@@ -1114,6 +1121,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
final List<Runnable> pulses = new ArrayList<>();
|
||||
final List<Runnable> delayed = new ArrayList<>();
|
||||
final List<Long> delayedTicks = new ArrayList<>();
|
||||
final List<org.bukkit.boss.BossBar> bossBars = new ArrayList<>();
|
||||
com.comphenix.protocol.events.PacketListener packetListener;
|
||||
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
|
||||
final Thread main = Thread.currentThread();
|
||||
@@ -1206,6 +1214,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "createInventory" -> args[1] instanceof Integer size
|
||||
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
|
||||
: new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], (org.bukkit.event.inventory.InventoryType) args[1], (net.kyori.adventure.text.Component) args[2]);
|
||||
case "createBossBar" -> { var bar=mock(org.bukkit.boss.BossBar.class);bossBars.add(bar);yield bar; }
|
||||
case "addRecipe" -> {
|
||||
order.add("recipe");
|
||||
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
|
||||
@@ -1288,7 +1297,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(actor.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
private void captureWorkers(JavaPlugin instance) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies")) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies",
|
||||
"hardMobXpSettings", "adventureQuestSettings", "adventureQuests")) {
|
||||
var store = field(instance, name);
|
||||
if (store != null) {
|
||||
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
|
||||
@@ -1322,6 +1332,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
// Location holds a WeakReference: the fixture must model the server's world ownership.
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class);
|
||||
final List<String> messages = new ArrayList<>();
|
||||
final List<net.kyori.adventure.text.Component> messageComponents = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
|
||||
int inventoryCloses;
|
||||
@@ -1337,6 +1348,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
healthAttributes.getInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
|
||||
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
|
||||
boolean healthScaled;
|
||||
boolean sprinting;
|
||||
double healthScale = 20;
|
||||
double absorption;
|
||||
Runnable scaleMutation = () -> {};
|
||||
@@ -1400,8 +1412,17 @@ class FantasyStickLifecycleNativeTest {
|
||||
yield null;
|
||||
}
|
||||
case "isSneaking", "isInvisible" -> false;
|
||||
case "isSprinting" -> sprinting;
|
||||
case "setSprinting" -> { sprinting = (Boolean) args[0]; yield null; }
|
||||
case "getScoreboard" -> craftBoard;
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
case "sendMessage" -> {
|
||||
if (args[0] instanceof net.kyori.adventure.text.Component component) {
|
||||
messageComponents.add(component);
|
||||
messages.add(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
|
||||
.serialize(component));
|
||||
} else messages.add((String) args[0]);
|
||||
yield null;
|
||||
}
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; }
|
||||
case "closeInventory" -> { inventoryCloses++; yield null; }
|
||||
|
||||
@@ -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", "shop", "training-dummy"),
|
||||
assertEquals(java.util.List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
|
||||
|
||||
@@ -200,7 +200,7 @@ class QuickstepNativeTest {
|
||||
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
|
||||
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"),
|
||||
assertEquals(List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
|
||||
|
||||
@@ -130,7 +130,7 @@ class SecondWindNativeTest {
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
|
||||
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"),
|
||||
assertEquals(List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class StickKnockbackNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookLearningRefreshesAuthenticStickAcceptedAttackTransfersAndUnlearns() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.passive.PassiveRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
|
||||
var definition = registry.find("stick_knockback").orElseThrow();
|
||||
assertEquals("Knockback", definition.name()); assertEquals(BigInteger.valueOf(12),
|
||||
definition.requirements().get(games.dmg.fantasystick.progression.Stat.STR));
|
||||
assertEquals(BigInteger.ONE, definition.pointCost()); assertTrue(definition.prerequisites().isEmpty());
|
||||
|
||||
var ineligible = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, ineligible, 11, List.of("", "", ""));
|
||||
f.players.put(ineligible.core.id, ineligible.player); f.activate(ineligible); PartyBookNativeTest.open(f, ineligible);
|
||||
var refusedPages = pages(ineligible); var refusedDetail = changePageTo(refusedPages, "Knockback"); assertNotNull(refusedDetail);
|
||||
String refused = refusedPages.get(refusedDetail.page() - 1).getString().replaceAll("\\s+", "");
|
||||
assertTrue(refused.contains("STR12")); assertNull(findCustom(refusedPages.get(refusedDetail.page() - 1),
|
||||
"fantasystick:passive_unlock"));
|
||||
ineligible.core.inventory.setItemInOffHand(null); f.changed(ineligible); f.awaitCharacterAndReceipt(ineligible);
|
||||
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12, List.of("", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor); PartyBookNativeTest.open(f, actor);
|
||||
var pages = pages(actor); var detail = changePageTo(pages, "Knockback"); assertNotNull(detail);
|
||||
assertTrue(pages.get(detail.page() - 1).getString().replaceAll("\\s+", "").contains("STR12[Learn]"));
|
||||
PartyBookNativeTest.click(f, actor, custom(pages.get(detail.page() - 1), "fantasystick:passive_unlock"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var learned = storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
assertEquals(List.of("stick_knockback", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
assertEquals(1, actor.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = target();
|
||||
doAnswer(call -> { accepted(f, actor, target, (Double) call.getArgument(0), false, false); return null; })
|
||||
.when(target).damage(anyDouble(), same(actor.player));
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
|
||||
verify(target).knockback(eq(1.0), anyDouble(), anyDouble());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
|
||||
verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
|
||||
actor.core.inventory.setItemInOffHand(null); f.changed(actor); f.awaitCharacterAndReceipt(actor);
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
holder.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(learned));
|
||||
f.players.put(holder.core.id, holder.player); f.activate(holder);
|
||||
assertEquals(1, holder.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
PartyBookNativeTest.open(f, holder);
|
||||
PartyBookNativeTest.click(f, holder, custom(pages(holder), "fantasystick:passive_unlearn"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage(f).find(learned.id()).get().orElseThrow();
|
||||
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertEquals(0, holder.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
holder.core.inventory.setItemInOffHand(null); f.changed(holder); f.awaitCharacterAndReceipt(holder);
|
||||
assertTrue(holder.core.inventory.getItem(1) == null || holder.core.inventory.getItem(1).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledZeroStaleEarlyAndTrainingHitsCannotKnockBackWhileNativeSprintStrengthIsRetained() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12,
|
||||
List.of("stick_knockback", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(actor.core.id).orElseThrow();
|
||||
var clock = new BasicAttackNativeTest.MutableClock(java.time.Instant.parse("2026-09-21T00:00:00Z"));
|
||||
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
|
||||
var target = target();
|
||||
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
doAnswer(call -> {
|
||||
var event = accepted(f, actor, target, (Double) call.getArgument(0), cancelled.get(), zero.get());
|
||||
basic.acceptedDamage(event); return null;
|
||||
}).when(target).damage(anyDouble(), same(actor.player));
|
||||
var input = new games.dmg.fantasystick.session.ControlInput(actor.player, active, 1, "",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> sessions.current(active));
|
||||
basic.accept(input); verify(target, never()).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
cancelled.set(false); zero.set(true); basic.accept(input);
|
||||
verify(target, never()).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
zero.set(false); actor.sprinting = true;
|
||||
basic.accept(input); verify(target).knockback(eq(2.0), anyDouble(), anyDouble()); assertFalse(actor.sprinting);
|
||||
basic.accept(input); verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
actor.core.inventory.setItemInOffHand(null); f.changed(actor);
|
||||
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
|
||||
verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
|
||||
var current = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, current, 12,
|
||||
List.of("stick_knockback", "", "")); f.players.put(current.core.id, current.player); f.activate(current);
|
||||
var currentSession = sessions.active(current.core.id).orElseThrow();
|
||||
var dummy = target();
|
||||
var training = new games.dmg.fantasystick.training.TrainingDamage() {
|
||||
@Override public java.util.Optional<java.math.BigDecimal> apply(org.bukkit.entity.Player source,
|
||||
LivingEntity ignored, Runnable nativeDamage) {
|
||||
return java.util.Optional.of(java.math.BigDecimal.ONE);
|
||||
}
|
||||
@Override public boolean target(org.bukkit.entity.Entity entity) { return entity == dummy; }
|
||||
};
|
||||
var measured = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock, null, training);
|
||||
measured.accept(new games.dmg.fantasystick.session.ControlInput(current.player, currentSession, 1, "",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, dummy, 0, 1,
|
||||
() -> sessions.current(currentSession)));
|
||||
verify(dummy, never()).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
}
|
||||
}
|
||||
|
||||
private static LivingEntity target() {
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return target;
|
||||
}
|
||||
|
||||
private static EntityDamageByEntityEvent accepted(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, LivingEntity target, double amount, boolean cancelled, boolean zero) {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(actor.player); when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var event = new EntityDamageByEntityEvent(actor.player, target, DamageCause.ENTITY_ATTACK, source, amount);
|
||||
event.setCancelled(cancelled); if (zero) event.setDamage(0); f.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength,
|
||||
List<String> passives) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength));
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
|
||||
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), BigInteger.valueOf(40), old.health(),
|
||||
old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null,
|
||||
old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()), old.revision());
|
||||
}
|
||||
|
||||
private static games.dmg.fantasystick.persistence.CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static List<net.minecraft.network.chat.Component> pages(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream().map(page -> page.raw()).toList();
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(
|
||||
List<net.minecraft.network.chat.Component> pages, String heading) {
|
||||
for (var page : pages) { var found = changePageTo(page, pages, heading); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(net.minecraft.network.chat.Component component,
|
||||
List<net.minecraft.network.chat.Component> pages, String heading) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page
|
||||
&& page.page() > 0 && page.page() <= pages.size()
|
||||
&& pages.get(page.page() - 1).getString().replaceAll("\\s+", "")
|
||||
.startsWith(heading.replaceAll("\\s+", ""))) return page;
|
||||
for (var child : component.getSiblings()) { var found = changePageTo(child, pages, heading); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(net.minecraft.network.chat.Component component, String id) {
|
||||
var found = findCustom(component, id);
|
||||
if (found == null) throw new AssertionError("Missing custom action " + id);
|
||||
return found;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
|
||||
for (var page : pages) { var found = findCustom(page, id); if (found != null) return found; }
|
||||
throw new AssertionError("Missing custom action " + id);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom findCustom(
|
||||
net.minecraft.network.chat.Component component, String id) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom action
|
||||
&& action.id().toString().equals(id)) return action;
|
||||
for (var child : component.getSiblings()) { var found = findCustom(child, id); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,12 @@ class DistributionTest {
|
||||
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
|
||||
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback"), metadata.getCommands().keySet());
|
||||
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
|
||||
assertNull(metadata.getCommands().get("fantasystick").get("permission"),
|
||||
"Player adventure inspection must reach command-level authorization");
|
||||
assertNull(metadata.getCommands().get("fantasyfeedback").get("permission"));
|
||||
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
|
||||
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>"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("training-dummy <place [name...]|remove <dummy-uuid>|list>"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("adventure-site"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("quest-giver"));
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
|
||||
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
|
||||
@@ -48,6 +48,13 @@ class DistributionTest {
|
||||
assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"),
|
||||
"The delivered configuration must expose the reviewed 15-minute default");
|
||||
assertTrue(defaults.contains("adventure-mode: true"));
|
||||
for (String setting : java.util.List.of("adventure-daily-site-count: 3", "adventure-site-radius: 16",
|
||||
"adventure-site-reset-seconds: 30", "adventure-camp-enemies: 8",
|
||||
"adventure-camp-reward-xp: 100", "adventure-camp-reward-coins: 20",
|
||||
"adventure-champion-health: 100", "adventure-champion-damage: 8",
|
||||
"adventure-champion-reward-xp: 150", "adventure-champion-reward-coins: 40")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged adventure default: " + setting);
|
||||
}
|
||||
assertTrue(defaults.contains("great-sword-base-damage: 7"));
|
||||
assertTrue(defaults.contains("great-sword-recovery-seconds: 0.75"));
|
||||
for (String setting : java.util.List.of("frost-shard-base-damage: 4",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdventureQuestDirectoryTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void catalogRestartRolloverAcceptanceRosterClaimsAndRemovalAreDurable() throws Exception {
|
||||
var clock = new MutableClock(Instant.parse("2026-09-21T12:00:00Z"));
|
||||
var rules = new AdventureQuestSettings.Rules(3, 16, 30, 8, BigInteger.valueOf(100), BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
|
||||
UUID world = UUID.randomUUID();
|
||||
AdventureQuestState.Catalog first;
|
||||
UUID solo = UUID.randomUUID(), party = UUID.randomUUID(), member = UUID.randomUUID();
|
||||
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
|
||||
() -> rules, new Random(7))) {
|
||||
for (int index = 0; index < 6; index++) directory.placeSite(AdventureQuestState.SiteType.CAMP,
|
||||
"Camp " + index, world, index * 20, 64, 0).get(5, TimeUnit.SECONDS);
|
||||
directory.placeGiver("Guide", world, 0, 64, 0, 90, 0).get(5, TimeUnit.SECONDS);
|
||||
first = directory.ensureToday().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(LocalDate.of(2026, 9, 21), first.day()); assertEquals(3, first.sites().size());
|
||||
assertEquals(first, directory.ensureToday().get(5, TimeUnit.SECONDS), "Current UTC day never rerolls");
|
||||
UUID site = first.sites().getFirst();
|
||||
var accepted = directory.accept(first.revision(), site, AdventureQuestState.UnitType.SOLO, solo,
|
||||
Set.of(solo)).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED, accepted.status());
|
||||
assertEquals(BigInteger.valueOf(100), accepted.quest().orElseThrow().rewardXp());
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.CONFLICT, directory.accept(first.revision(),
|
||||
first.sites().get(1), AdventureQuestState.UnitType.SOLO, solo, Set.of(solo)).get().status());
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.CONFLICT, directory.accept(first.revision(),
|
||||
first.sites().get(1), AdventureQuestState.UnitType.PARTY, party, Set.of(solo, member)).get().status());
|
||||
assertTrue(directory.abandon(accepted.quest().orElseThrow().id(), solo).get(5, TimeUnit.SECONDS));
|
||||
var partyQuest = directory.accept(first.revision(), site, AdventureQuestState.UnitType.PARTY, party,
|
||||
Set.of(solo, member)).get(5, TimeUnit.SECONDS).quest().orElseThrow();
|
||||
var roster = directory.roster(site).get(5, TimeUnit.SECONDS); assertEquals(8, roster.size());
|
||||
assertTrue(directory.complete(partyQuest.id(), Set.of(solo, member)).get(5, TimeUnit.SECONDS));
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.CLAIMED, directory.accept(first.revision(), site,
|
||||
AdventureQuestState.UnitType.SOLO, solo, Set.of(solo)).get().status());
|
||||
}
|
||||
try (var restarted = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
|
||||
() -> rules, new Random(999))) {
|
||||
assertEquals(first, restarted.ensureToday().get(5, TimeUnit.SECONDS));
|
||||
assertEquals(8, restarted.roster(first.sites().getFirst()).get(5, TimeUnit.SECONDS).size());
|
||||
clock.advance(Duration.ofDays(1));
|
||||
var second = restarted.ensureToday().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(LocalDate.of(2026, 9, 22), second.day());
|
||||
assertTrue(Collections.disjoint(first.sites(), second.sites()), "Enough alternatives exclude yesterday's sites");
|
||||
UUID removed = second.sites().getFirst();
|
||||
var quest = restarted.accept(second.revision(), removed, AdventureQuestState.UnitType.SOLO,
|
||||
member, Set.of(member)).get(5, TimeUnit.SECONDS).quest().orElseThrow();
|
||||
assertTrue(restarted.removeSite(removed).get(5, TimeUnit.SECONDS).isPresent());
|
||||
assertTrue(restarted.findQuest(quest.id()).isEmpty(), "Removing an offered site expires its quests");
|
||||
assertTrue(restarted.ensureToday().get().sites().contains(removed), "Catalog slot stays unavailable until reset");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void championAcceptanceCapturesChampionPoolWithoutCreatingACampRoster() throws Exception {
|
||||
var rules=new AdventureQuestSettings.Rules(3,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
|
||||
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC),()->rules,new Random(1))){
|
||||
var site=directory.placeSite(AdventureQuestState.SiteType.CHAMPION,"Summit",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS);
|
||||
var catalog=directory.ensureToday().get(5,TimeUnit.SECONDS);var character=UUID.randomUUID();
|
||||
var quest=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.SOLO,character,Set.of(character)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(150),quest.rewardXp());assertEquals(BigInteger.valueOf(40),quest.rewardCoins());
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.roster(site.id()).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void confirmedCurrentDayRerollPrefersAlternativesPreservesClaimsAndSurvivesRestart() throws Exception {
|
||||
var clock=new MutableClock(Instant.parse("2026-09-22T00:20:00Z"));
|
||||
var rules=new AdventureQuestSettings.Rules(2,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
|
||||
UUID character=UUID.randomUUID(),claimed;AdventureQuestState.Catalog rerolled;
|
||||
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(4))){
|
||||
for(int i=0;i<4;i++)directory.placeSite(AdventureQuestState.SiteType.CAMP,"Camp "+i,UUID.randomUUID(),i,64,0).get(5,TimeUnit.SECONDS);
|
||||
var first=directory.ensureToday().get(5,TimeUnit.SECONDS);claimed=first.sites().getFirst();var quest=directory.accept(first.revision(),claimed,AdventureQuestState.UnitType.SOLO,character,Set.of(character)).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(directory.complete(quest.id(),Set.of(character)).get(5,TimeUnit.SECONDS));directory.roster(first.sites().get(1)).get(5,TimeUnit.SECONDS);assertFalse(directory.snapshot().rosters().isEmpty());
|
||||
var result=directory.rerollToday().get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.APPLIED,result.status());var alternate=result.catalog().orElseThrow();var stale=directory.rerollToday(first.revision()).get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.STALE,stale.status());assertEquals(alternate,directory.snapshot().catalog());assertEquals(first.day(),alternate.day());assertEquals(first.expiresAt(),alternate.expiresAt());assertNotEquals(first.revision(),alternate.revision());assertTrue(Collections.disjoint(first.sites(),alternate.sites()));assertTrue(directory.snapshot().rosters().isEmpty());assertTrue(directory.claimed(alternate.revision(),claimed,character,null));
|
||||
rerolled=directory.rerollToday().get(5,TimeUnit.SECONDS).catalog().orElseThrow();assertTrue(rerolled.sites().contains(claimed));assertEquals(AdventureQuestDirectory.Acceptance.CLAIMED,directory.accept(rerolled.revision(),claimed,AdventureQuestState.UnitType.SOLO,character,Set.of(character)).get(5,TimeUnit.SECONDS).status());
|
||||
var activeCharacter=UUID.randomUUID();var active=directory.accept(rerolled.revision(),rerolled.sites().stream().filter(site->!site.equals(claimed)).findFirst().orElseThrow(),AdventureQuestState.UnitType.SOLO,activeCharacter,Set.of(activeCharacter)).get(5,TimeUnit.SECONDS).quest().orElseThrow();var blocked=directory.rerollToday().get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.ACTIVE,blocked.status());assertEquals(rerolled,directory.snapshot().catalog());assertTrue(directory.findQuest(active.id()).isPresent());
|
||||
}
|
||||
try(var restarted=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(99))){assertEquals(rerolled,restarted.ensureToday().get(5,TimeUnit.SECONDS));assertTrue(restarted.claimed(rerolled.revision(),claimed,character,null));}
|
||||
}
|
||||
|
||||
@Test void emptyRerollIsDurableAndFailedReplacementRetainsPriorCatalog() throws Exception {
|
||||
var rules=new AdventureQuestSettings.Rules(3,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));var clock=Clock.fixed(Instant.parse("2026-09-22T00:30:00Z"),ZoneOffset.UTC);
|
||||
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){var first=directory.ensureToday().get(5,TimeUnit.SECONDS);var empty=directory.rerollToday().get(5,TimeUnit.SECONDS).catalog().orElseThrow();assertTrue(empty.sites().isEmpty());assertNotEquals(first.revision(),empty.revision());var file=data.resolve("adventure-quests.yml");java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);java.nio.file.Files.writeString(file.resolve("block"),"retain");assertThrows(java.util.concurrent.ExecutionException.class,()->directory.rerollToday().get(5,TimeUnit.SECONDS));assertEquals(empty,directory.snapshot().catalog());}
|
||||
}
|
||||
|
||||
@Test void failedAdventureWriteRetainsPublishedState() throws Exception {
|
||||
var blocked=data.resolve("blocked");java.nio.file.Files.writeString(blocked,"not a directory");
|
||||
var rules=AdventureQuestSettings.parse(java.util.Map.of(
|
||||
AdventureQuestSettings.DAILY_COUNT,"3",AdventureQuestSettings.RADIUS,"16",
|
||||
AdventureQuestSettings.RESET_SECONDS,"30",AdventureQuestSettings.CAMP_ENEMIES,"8",
|
||||
AdventureQuestSettings.CAMP_REWARD_XP,"100",AdventureQuestSettings.CAMP_REWARD_COINS,"20",
|
||||
AdventureQuestSettings.CHAMPION_HEALTH,"100",AdventureQuestSettings.CHAMPION_DAMAGE,"8",
|
||||
AdventureQuestSettings.CHAMPION_REWARD_XP,"150",AdventureQuestSettings.CHAMPION_REWARD_COINS,"40"));
|
||||
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(blocked),Clock.systemUTC(),()->rules,new Random(1))){
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.placeSite(AdventureQuestState.SiteType.CAMP,"Camp",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS));
|
||||
assertTrue(directory.snapshot().sites().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant value; MutableClock(Instant value){this.value=value;} void advance(Duration amount){value=value.plus(amount);}
|
||||
@Override public ZoneId getZone(){return ZoneOffset.UTC;} @Override public Clock withZone(ZoneId zone){return this;}
|
||||
@Override public Instant instant(){return value;}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CampRosterTest {
|
||||
@Test void exactWeightedEndpointsGuaranteesAndCapsAreDeterministicBeforeShuffle() {
|
||||
var rolls = new ArrayDeque<>(java.util.List.of(39, 40, 74, 75, 94, 95));
|
||||
var roster = CampRoster.roll(12, () -> rolls.removeFirst(), bound -> 0);
|
||||
assertEquals(12, roster.size()); assertEquals(7, count(roster, EntityType.HUSK));
|
||||
assertEquals(2, count(roster, EntityType.PILLAGER)); assertEquals(2, count(roster, EntityType.VINDICATOR));
|
||||
assertEquals(1, count(roster, EntityType.WITCH));
|
||||
|
||||
var witches = CampRoster.roll(8, () -> 95, bound -> bound - 1);
|
||||
assertEquals(7, count(witches, EntityType.HUSK)); assertEquals(1, count(witches, EntityType.WITCH));
|
||||
var vindicators = CampRoster.roll(8, () -> 75, bound -> 0);
|
||||
assertEquals(6, count(vindicators, EntityType.HUSK)); assertEquals(2, count(vindicators, EntityType.VINDICATOR));
|
||||
var pillagers = CampRoster.roll(8, () -> 40, bound -> 0);
|
||||
assertEquals(4, count(pillagers, EntityType.HUSK)); assertEquals(4, count(pillagers, EntityType.PILLAGER));
|
||||
assertThrows(IllegalArgumentException.class, () -> CampRoster.roll(3, () -> 0, bound -> 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> CampRoster.roll(8, () -> 100, bound -> 0));
|
||||
}
|
||||
|
||||
private static long count(java.util.List<EntityType> roster, EntityType type) {
|
||||
return roster.stream().filter(type::equals).count();
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
class BasicAttackTest {
|
||||
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
|
||||
for (int strength : new int[]{0, 9, 10, 11})
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
|
||||
for (int strength : new int[]{12, 13})
|
||||
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
|
||||
for (int strength : new int[]{14, 15})
|
||||
assertEquals(BigInteger.valueOf(3), BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
|
||||
var huge = BigInteger.TEN.pow(1000);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.ONE), BasicAttack.damage(huge));
|
||||
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
|
||||
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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.LinkedHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdventureQuestSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void packagedValuesBoundsAndSaveBeforePublicationAreDurable() throws Exception {
|
||||
var initial = new LinkedHashMap<String, String>();
|
||||
for (String key : AdventureQuestSettings.keys()) initial.put(key, AdventureQuestSettings.defaultValue(key));
|
||||
var rules = AdventureQuestSettings.parse(initial);
|
||||
assertEquals(3, rules.dailySiteCount()); assertEquals(16, rules.radius()); assertEquals(30, rules.resetSeconds());
|
||||
assertEquals(8, rules.campEnemies()); assertEquals(java.math.BigInteger.valueOf(100), rules.campRewardXp());
|
||||
assertEquals(java.math.BigInteger.valueOf(20), rules.campRewardCoins());
|
||||
assertEquals(100, rules.championHealth()); assertEquals(8, rules.championDamage());
|
||||
assertEquals(java.math.BigInteger.valueOf(150), rules.championRewardXp());
|
||||
assertEquals(java.math.BigInteger.valueOf(40), rules.championRewardCoins());
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new AdventureQuestSettings(data, initial)) {
|
||||
var update = settings.set(AdventureQuestSettings.CAMP_ENEMIES, "64").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("8", settings.value(AdventureQuestSettings.CAMP_ENEMIES));
|
||||
assertTrue(settings.publish(update)); assertEquals("64", settings.value(AdventureQuestSettings.CAMP_ENEMIES));
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: retained"));
|
||||
}
|
||||
assertInvalid(initial, AdventureQuestSettings.DAILY_COUNT, "21");
|
||||
assertInvalid(initial, AdventureQuestSettings.RADIUS, "3");
|
||||
assertInvalid(initial, AdventureQuestSettings.RESET_SECONDS, "601");
|
||||
assertInvalid(initial, AdventureQuestSettings.CAMP_ENEMIES, "3");
|
||||
assertInvalid(initial, AdventureQuestSettings.CAMP_REWARD_XP, "-1");
|
||||
assertInvalid(initial, AdventureQuestSettings.CAMP_REWARD_COINS, "2147483648");
|
||||
assertInvalid(initial, AdventureQuestSettings.CHAMPION_HEALTH, "0");
|
||||
assertInvalid(initial, AdventureQuestSettings.CHAMPION_HEALTH, "2049");
|
||||
assertInvalid(initial, AdventureQuestSettings.CHAMPION_DAMAGE, "0");
|
||||
assertInvalid(initial, AdventureQuestSettings.CHAMPION_DAMAGE, "2049");
|
||||
assertInvalid(initial, AdventureQuestSettings.CHAMPION_REWARD_XP, "-1");
|
||||
assertInvalid(initial, AdventureQuestSettings.CHAMPION_REWARD_COINS, "2147483648");
|
||||
var blocked=data.resolve("blocked");Files.writeString(blocked,"not a directory");
|
||||
try(var settings=new AdventureQuestSettings(blocked,initial)){
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(AdventureQuestSettings.CHAMPION_HEALTH,"120").toCompletableFuture().get(5,TimeUnit.SECONDS));
|
||||
assertEquals("100",settings.value(AdventureQuestSettings.CHAMPION_HEALTH));
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertInvalid(java.util.Map<String, String> initial, String key, String value) {
|
||||
var changed = new LinkedHashMap<>(initial); changed.put(key, value);
|
||||
assertThrows(IllegalArgumentException.class, () -> AdventureQuestSettings.parse(changed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HardMobXpSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void validatesWholePoolAndPersistsBeforePublicationWithoutLosingOtherKeys() throws Exception {
|
||||
assertEquals(BigInteger.ZERO, HardMobXpSettings.parse("0"));
|
||||
assertEquals(BigInteger.valueOf(20), HardMobXpSettings.parse("20"));
|
||||
assertEquals(BigInteger.valueOf(Integer.MAX_VALUE), HardMobXpSettings.parse("2147483647"));
|
||||
for (String invalid : java.util.List.of("-1", "2147483648", "1.5", "x"))
|
||||
assertThrows(IllegalArgumentException.class, () -> HardMobXpSettings.parse(invalid));
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new HardMobXpSettings(data, "20")) {
|
||||
var update = settings.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("20", settings.value());
|
||||
assertTrue(settings.publish(update)); assertEquals("25", settings.value());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("25", String.valueOf(saved.get(HardMobXpSettings.KEY)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedWriteCloseAndSupersededPublicationRetainAuthoritativeValue() throws Exception {
|
||||
try (var settings = new HardMobXpSettings(data, "20", (from, to) -> {
|
||||
throw new java.io.IOException("simulated");
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("20", settings.value());
|
||||
}
|
||||
try (var settings = new HardMobXpSettings(data.resolve("ordered"), "20")) {
|
||||
var first = settings.set("21").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var second = settings.set("22").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertTrue(settings.publish(second)); assertFalse(settings.publish(first)); assertEquals("22", settings.value());
|
||||
}
|
||||
var closed = new HardMobXpSettings(data.resolve("closed"), "20"); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
class CombatRewardRulesTest {
|
||||
@Test void poolsAndRoundedSharesPreserveApprovedArithmetic() {
|
||||
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), new BigDecimal("3"));
|
||||
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(20),
|
||||
BigInteger.valueOf(100), new BigDecimal("3"));
|
||||
assertEquals(BigInteger.ONE, rules.pool(CombatRewardRules.Target.PASSIVE, 99));
|
||||
assertEquals(BigInteger.TEN, rules.pool(CombatRewardRules.Target.ORDINARY, 99));
|
||||
assertEquals(BigInteger.valueOf(20), rules.pool(CombatRewardRules.Target.HARD, 99));
|
||||
assertEquals(BigInteger.valueOf(100), rules.pool(CombatRewardRules.Target.BOSS, 99));
|
||||
assertEquals(BigInteger.valueOf(70), rules.pool(CombatRewardRules.Target.FANTASY_CHARACTER, 7));
|
||||
assertEquals(BigInteger.valueOf(4), rules.share(BigInteger.TEN, 3));
|
||||
@@ -41,7 +43,10 @@ class CombatRewardRulesTest {
|
||||
|
||||
@Test void rejectsInvalidRewardConfigurationAndArithmeticInputs() {
|
||||
assertAll(
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN, BigInteger.TEN, BigDecimal.ONE)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN,
|
||||
BigInteger.valueOf(20), BigInteger.TEN, BigDecimal.ONE)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN,
|
||||
BigInteger.valueOf(-1), BigInteger.TEN, BigDecimal.ONE)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.TEN, new BigDecimal("-0.1"))),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().share(BigInteger.ONE, 0)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().pool(CombatRewardRules.Target.FANTASY_CHARACTER, 0)),
|
||||
|
||||
Reference in new issue
Block a user