Compare commits

...
8 Commits
Author SHA1 Message Date
dmg fa219d9313 feat(rewards): add Witch hard-mob XP
Release / release (push) Successful in 8m30s
2026-09-21 18:21:10 -04:00
dmg d98a2a80a3 feat(combat): add base damage to basic attack
Release / release (push) Successful in 9m29s
2026-09-21 18:06:37 -04:00
dmg 7a85eea769 feat(book): add passive return navigation
Release / release (push) Successful in 9m24s
2026-09-21 17:19:47 -04:00
dmg 6d0d695052 feat(book): categorize active abilities
Release / release (push) Successful in 9m53s
2026-09-21 17:09:03 -04:00
dmg 710a29a43c feat(shield-bash): shorten strike cooldown
Release / release (push) Successful in 10m4s
2026-09-21 16:52:02 -04:00
dmg d3f58f4223 feat(great-sword): shorten attack recovery
Release / release (push) Successful in 10m13s
2026-09-21 16:43:22 -04:00
dmg 87024bfd24 feat(recovery): shorten initial healing delay
Release / release (push) Successful in 9m51s
2026-09-21 16:31:18 -04:00
dmg d8bbe78a08 feat(frost-shard): use fast arrow flight
Release / release (push) Successful in 9m56s
2026-09-21 16:23:46 -04:00
51 changed files with 911 additions and 151 deletions

No files matched your search

@@ -119,6 +119,7 @@ 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.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
@@ -186,7 +187,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 +235,8 @@ 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());
var factory = new CharacterFactory(coinSettings::rules);
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC(),
coinSettings::rules, java.util.random.RandomGenerator.getDefault());
@@ -549,6 +556,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachArmorers(armorers,armorerController);
admin.attachArmorMarket(armorMarket);
admin.attachCoinSettings(coinSettings);
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
@@ -716,6 +724,7 @@ 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 closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
@@ -780,6 +789,7 @@ 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 (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 +812,8 @@ 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(),
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -969,13 +981,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);
}
@@ -1171,7 +1183,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "1.2"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
} catch (ArithmeticException | NumberFormatException invalid) {
@@ -13,9 +13,10 @@ import org.bukkit.Material;
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Category category, Activation activation,
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
TargetPolicy targetPolicy, AbilityEffect effect) {
public enum Category { ATK, BUF, HEAL }
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
EXCEPTIONAL_NONCOMBAT_SELECTION }
@@ -25,7 +26,7 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
public AbilityDefinition {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(pointCost); Objects.requireNonNull(category); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
Objects.requireNonNull(effect);
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
@@ -72,7 +72,7 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
@@ -76,7 +76,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery arrow flight.", "Deals " + current.baseDamage() + " + INT modifier damage."),
"Use the equipped snowball; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
@@ -102,7 +102,7 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
+ current.baseDamage() + " + INT modifier damage.",
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
@@ -15,11 +15,13 @@ import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.potion.PotionEffect;
@@ -45,12 +47,12 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
1, 0, Duration.ofSeconds(2));
3, 0, Duration.ofSeconds(2));
}
}
private final class State implements AutoCloseable {
final Snowball projectile;
final Arrow projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
@@ -58,7 +60,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
LivingEntity target;
boolean accepted;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
this.intelligence = intelligence; this.settings = settings;
}
@@ -112,7 +114,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
"Use the equipped shard; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
@@ -144,7 +146,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Frost Shard direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(current.speedPerTick()));
var projectile = player.launchProjectile(Arrow.class, direction.normalize().multiply(current.speedPerTick()));
projectile.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
projectile.setDamage(0); projectile.setKnockbackStrength(0); projectile.setPierceLevel(0); projectile.setCritical(false);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Frost Shard projectile identity");
@@ -160,6 +164,13 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void containNativeDamage(EntityDamageByEntityEvent event) {
if (!(event.getDamager() instanceof Arrow arrow)) return;
var state = projectiles.get(arrow.getUniqueId());
if (state != null && damaging != state) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
@@ -27,7 +27,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
throw new IllegalArgumentException("Invalid Great Sword settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(750)); }
}
private static final class Attempt {
@@ -50,7 +50,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
List.of("Charge a contained melee strike.",
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
"Attack with the equipped sword; partial charge follows the native attack curve.",
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
@@ -130,7 +130,7 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
List.of("Redirect " + decimal(current.redirectPercent()) + "% of an aimed party ally's combat damage.",
"Range " + decimal(BigDecimal.valueOf(current.range())) + " blocks for " + seconds(current.duration()) + " seconds."),
"Use on an aimed active party ally; one guardian and one bond per ally.",
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
@@ -30,7 +30,7 @@ public final class Hex implements AbilityEffect,AutoCloseable{
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("hex","Hex",Material.SPIDER_EYE,
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("range",BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@@ -5,7 +5,7 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
@@ -83,7 +83,7 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
List.of("Draw and release an ammunition-free owned arrow.",
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
@@ -70,7 +70,7 @@ public final class MendingLight implements AbilityEffect {
List.of("Restore a party ally's missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
@@ -59,7 +59,7 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@@ -58,7 +58,7 @@ public final class Purify implements AbilityEffect {
List.of("Remove Poison, Wither, Slowness and Weakness.",
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PARTY, this);
@@ -80,7 +80,7 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
List.of("Dash forward with native horizontal movement.",
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
"Use while grounded, on foot, and not flying or gliding.",
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
@@ -175,7 +175,7 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
"Use to rally yourself and nearby active party members; copies do not stack.",
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
@@ -85,7 +85,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
"Use the equipped shard while aiming at another player within 12 blocks.",
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
BigInteger.ONE, AbilityDefinition.Category.HEAL, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
"death-age", BigDecimal.valueOf(86400)),
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
@@ -174,7 +174,7 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
@@ -51,7 +51,7 @@ public final class SecondWind implements AbilityEffect {
List.of("Restore missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
"Use while injured; full health refuses without spending cooldown.",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing())),
AbilityDefinition.TargetPolicy.SELF, this);
@@ -35,7 +35,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(3), 3, 0.8, 0.35);
}
}
private static final class Attempt {
@@ -61,7 +61,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
BigInteger.ONE, AbilityDefinition.Category.ATK, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
@@ -53,7 +53,7 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
@@ -181,7 +181,7 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
@@ -142,7 +142,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
.toList();
var passiveDefinitions = passives.definitions().stream()
@@ -158,35 +159,51 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
games.dmg.fantasystick.passive.PassiveDefinition::name));
var passiveDetails = passiveDefinitions.stream()
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView)).toList();
var placeholderPassiveDetails = passiveDefinitions.stream()
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView, 1)).toList();
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
var passivePlaceholderTargets = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id, ignored -> 1));
var placeholderDetails = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, 1)).toList();
int activeCount = activePages(record, definitions, placeholderTargets).size();
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
int indexCount = indexPages(definitions, placeholderTargets).size();
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).size();
var placeholderIndex = indexPages(definitions, placeholderTargets);
int indexCount = placeholderIndex.pages().size();
var placeholderPassiveIndex = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets);
int passiveIndexCount = placeholderPassiveIndex.pages().size();
int indexStart = pages.size() + activeCount + passiveCount;
var indexTargets = new java.util.HashMap<String, Integer>();
placeholderIndex.relativePageByAbility().forEach((id, page) -> indexTargets.put(id, indexStart + page + 1));
int passiveIndexStart = indexStart + indexCount;
var passiveIndexTargets = new java.util.HashMap<String, Integer>();
placeholderPassiveIndex.relativePageByPassive().forEach((id, page) ->
passiveIndexTargets.put(id, passiveIndexStart + page + 1));
var targets = new java.util.HashMap<String, Integer>();
int target = pages.size() + activeCount + passiveCount + indexCount + passiveIndexCount + 1;
for (int index = 0; index < definitions.size(); index++) {
targets.put(definitions.get(index).id(), target);
target += details.get(index).size();
target += placeholderDetails.get(index).size();
}
var passiveTargets = new java.util.HashMap<String, Integer>();
for (int index = 0; index < passiveDefinitions.size(); index++) {
passiveTargets.put(passiveDefinitions.get(index).id(), target);
target += passiveDetails.get(index).size();
target += placeholderPassiveDetails.get(index).size();
}
var index = indexPages(definitions, targets);
var details = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, indexTargets.get(definition.id()))).toList();
var passiveIndex = passiveIndexPages(passiveDefinitions, passiveTargets);
var passiveDetails = passiveDefinitions.stream().map(definition -> passiveDetailPages(record, definition,
passiveNames, passiveView, passiveIndexTargets.get(definition.id()))).toList();
pages.addAll(activePages(record, definitions, targets));
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
pages.addAll(indexPages(definitions, targets));
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
pages.addAll(index.pages());
pages.addAll(passiveIndex.pages());
details.forEach(pages::addAll);
passiveDetails.forEach(pages::addAll);
}
@@ -274,34 +291,61 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return builder.pages();
}
private static java.util.List<Component> indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
private record AbilityIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByAbility) {
private AbilityIndex { pages = java.util.List.copyOf(pages); relativePageByAbility = Map.copyOf(relativePageByAbility); }
}
private static java.util.List<Component> passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
private static AbilityIndex indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var relativePages = new java.util.HashMap<String, Integer>();
for (var category : games.dmg.fantasystick.ability.AbilityDefinition.Category.values()) {
var grouped = definitions.stream().filter(definition -> definition.category() == category).toList();
int cursor = 0;
while (cursor < grouped.size()) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.text(category.name());
int usedLines = 0;
while (cursor < grouped.size()) {
var definition = grouped.get(cursor);
int linkLines = BookPagination.lines(definition.name()).size();
if (usedLines > 0 && usedLines + linkLines > 11) break;
relativePages.put(definition.id(), pages.size());
builder.link("", definition.name(), targets.get(definition.id()));
usedLines += linkLines; cursor++;
}
pages.addAll(builder.pages());
}
}
return new AbilityIndex(pages, relativePages);
}
private record PassiveIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByPassive) {
private PassiveIndex { pages = java.util.List.copyOf(pages); relativePageByPassive = Map.copyOf(relativePageByPassive); }
}
private static PassiveIndex passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>(); var relativePages = new java.util.HashMap<String, Integer>();
int cursor = 0;
while (cursor < definitions.size()) {
var builder = new PageBuilder(); builder.text("Passive index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
int usedLines = 0;
while (cursor < definitions.size()) {
var definition = definitions.get(cursor);
int linkLines = BookPagination.lines(definition.name()).size();
if (usedLines > 0 && usedLines + linkLines > 11) break;
relativePages.put(definition.id(), pages.size());
builder.link("", definition.name(), targets.get(definition.id()));
usedLines += linkLines; cursor++;
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
return new PassiveIndex(pages, relativePages);
}
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view) {
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view,
int indexPage) {
String requirements = definition.requirements().entrySet().stream().sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + " " + entry.getValue()).collect(java.util.stream.Collectors.joining(", "));
if (!definition.prerequisites().isEmpty()) {
@@ -322,11 +366,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
return builder.pages();
}
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view, int indexPage) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
@@ -339,6 +385,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
var builder = new PageBuilder();
builder.text(definition.name());
builder.text(definition.category().name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
@@ -351,6 +398,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(java.util.stream.Collectors.joining(", ")));
builder.text(state);
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
return builder.pages();
}
@@ -89,7 +89,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
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. */
@@ -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;
@@ -81,12 +81,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;
}
}
@@ -193,14 +194,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 +262,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 +322,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;
}
@@ -36,7 +36,7 @@ public final class FrostShardSettings implements AutoCloseable {
public static final String SLOWNESS = "frost-shard-slowness-duration-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE, AMPLIFIER, SLOWNESS);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "32",
LIFETIME, "5", SPEED, "1", AMPLIFIER, "0", SLOWNESS, "2");
LIFETIME, "5", SPEED, "3", AMPLIFIER, "0", SLOWNESS, "2");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, FrostShard.Settings settings,
@@ -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); }
}
@@ -30,7 +30,7 @@ public final class OutOfCombatRecoverySettings implements AutoCloseable {
public static final String INTERVAL = "out-of-combat-healing-interval-seconds";
public static final String AMOUNT = "out-of-combat-healing-amount";
private static final List<String> KEYS = List.of(DELAY, INTERVAL, AMOUNT);
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "30", INTERVAL, "10", AMOUNT, "1");
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "10", INTERVAL, "10", AMOUNT, "1");
private static final int MAX_BYTES = 1_048_576;
private static final long MAX_SECONDS = 31_536_000L;
@@ -33,7 +33,7 @@ public final class ShieldBashSettings implements AutoCloseable {
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "3", RANGE, "3",
HORIZONTAL, "0.8", VERTICAL, "0.35");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
@@ -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,8 @@ 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.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 +318,11 @@ 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 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) {
@@ -691,6 +698,9 @@ 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 (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 +1329,33 @@ 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 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);}});}
@@ -1387,6 +1424,7 @@ 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.OutOfCombatRecoverySettings.keys());
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
@@ -1420,6 +1458,10 @@ 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(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])) {
@@ -40,7 +40,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ONE);
return new Settings(Duration.ofSeconds(10), Duration.ofSeconds(10), BigInteger.ONE);
}
}
+5 -4
View File
@@ -33,7 +33,7 @@ sheet-resource-pack-sha1: ''
# Active-session recovery after actual accepted damage has stopped.
# Admin command: /fantasystick config <key> [value]
out-of-combat-healing-delay-seconds: 30
out-of-combat-healing-delay-seconds: 10
out-of-combat-healing-interval-seconds: 10
out-of-combat-healing-amount: 1
quicker-regeneration-i-bonus-healing: 1
@@ -56,6 +56,7 @@ 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
@@ -69,7 +70,7 @@ fireball-projectile-speed-blocks-per-tick: 3
# Great Sword defaults from the reviewed starter catalogue.
great-sword-base-damage: 7
great-sword-recovery-seconds: 1.2
great-sword-recovery-seconds: 0.75
# Frost Shard defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
@@ -77,7 +78,7 @@ frost-shard-base-damage: 4
frost-shard-cooldown-seconds: 5
frost-shard-range-blocks: 32
frost-shard-lifetime-seconds: 5
frost-shard-projectile-speed-blocks-per-tick: 1
frost-shard-projectile-speed-blocks-per-tick: 3
frost-shard-slowness-amplifier: 0
frost-shard-slowness-duration-seconds: 2
@@ -111,7 +112,7 @@ stone-skin-damage-reduction-percent: 25
# Shield Bash defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
shield-bash-base-damage: 4
shield-bash-cooldown-seconds: 6
shield-bash-cooldown-seconds: 3
shield-bash-range-blocks: 3
shield-bash-horizontal-knockback: 0.8
shield-bash-minimum-vertical-knockback: 0.35
@@ -135,7 +135,7 @@ class AbilityEngineNativeTest {
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
@@ -206,7 +206,7 @@ class AbilityEngineNativeTest {
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), BigInteger.ONE, AbilityDefinition.Category.ATK, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), targetPolicy, effect);
}
@@ -36,16 +36,19 @@ class AbilityRegistryNativeTest {
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
() -> assertThrows(NullPointerException.class, () -> new AbilityDefinition("category", "Category", Material.STONE,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, null, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {})),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Category.ATK, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
);
}
@@ -68,7 +71,7 @@ class AbilityRegistryNativeTest {
for (var policy : AbilityDefinition.TargetPolicy.values()) {
var base = definition("x");
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), base.category(), activation,
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
}
}
@@ -76,7 +79,7 @@ class AbilityRegistryNativeTest {
static AbilityDefinition definition(String id) {
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
@@ -85,6 +88,6 @@ class AbilityRegistryNativeTest {
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
Map<String, BigDecimal> parameters, Duration cooldown) {
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
value.pointCost(), value.category(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
}
}
@@ -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,6 +135,19 @@ class BasicAttackNativeTest {
}
}
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());
}
private static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
@@ -334,26 +334,60 @@ class CharacterBookNativeTest {
assertTrue(pages.get(2).getString().contains("3: No Ability"));
int index = firstPageStarting(pages, "Ability index");
assertTrue(index > 2);
var indexedNames = new java.util.ArrayList<String>();
var categorized = new java.util.LinkedHashMap<String, java.util.List<String>>();
var indexPageByAbility = new java.util.HashMap<String, Integer>();
int partyPage = firstPageStarting(pages, "Party");
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
indexedNames.add(line);
var jump = changePage(pages.get(page), line);
assertNotNull(jump, line);
var lines = pages.get(page).getString().split("\\n");
assertTrue(lines.length >= 2); String category = lines[1];
assertTrue(java.util.List.of("ATK", "BUF", "HEAL").contains(category));
var names = categorized.computeIfAbsent(category, ignored -> new java.util.ArrayList<>());
for (int line = 2; line < lines.length; line++) if (!lines[line].isBlank()) {
names.add(lines[line]); indexPageByAbility.put(lines[line], page);
var jump = changePage(pages.get(page), lines[line]);
assertNotNull(jump, lines[line]);
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
}
}
var sortedNames = indexedNames.stream().sorted().toList();
assertEquals(sortedNames, indexedNames);
assertEquals(20, indexedNames.size());
assertEquals(java.util.List.of("ATK", "BUF", "HEAL"), new java.util.ArrayList<>(categorized.keySet()));
assertEquals(java.util.List.of("Chain Lightning", "Fireball", "Frost Nova", "Frost Shard", "Great Sword",
"Hex", "Longbow", "Meteor", "Shield Bash"), categorized.get("ATK"));
assertEquals(java.util.List.of("Guardian Bond", "Inspire", "Purify", "Quickstep", "Rallying Cry",
"Stone Skin", "War Banner"), categorized.get("BUF"));
assertEquals(java.util.List.of("Mending Light", "Revive", "Sanctuary", "Second Wind"), categorized.get("HEAL"));
assertEquals(20, categorized.values().stream().mapToInt(java.util.List::size).sum());
var detailStartByAbility = new java.util.HashMap<String, Integer>();
for (var entry : indexPageByAbility.entrySet()) {
var jump = changePage(pages.get(entry.getValue()), entry.getKey()); assertNotNull(jump);
detailStartByAbility.put(entry.getKey(), jump.page() - 1);
}
int passiveIndex = firstPageStarting(pages, "Passive index");
int firstPassiveDetail = Integer.MAX_VALUE;
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Passive index")) {
var jump = changePage(pages.get(page), line); if (jump != null) firstPassiveDetail = Math.min(firstPassiveDetail, jump.page() - 1);
}
}
for (var entry : indexPageByAbility.entrySet()) {
int detailPage = detailStartByAbility.get(entry.getKey());
assertTrue(pages.get(detailPage).getString().startsWith(entry.getKey() + "\n"));
assertTrue(pages.get(detailPage).getString().split("\\n")[1].matches("ATK|BUF|HEAL"));
int end = firstPassiveDetail;
for (int other : detailStartByAbility.values()) if (other > detailPage) end = Math.min(end, other);
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = detailPage; candidate < end; candidate++)
if (changePage(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
var back = changePage(pages.get(end - 1), "[Back to index]"); assertNotNull(back);
assertEquals(entry.getValue() + 1, back.page(), entry.getKey());
}
var fireballJump = changePage(pages.get(index), "Fireball");
assertNotNull(fireballJump);
var detail = pages.get(fireballJump.page() - 1);
assertTrue(detail.getString().startsWith("Fireball\nINT 12\n[Learn]"), detail.getString());
assertTrue(detail.getString().startsWith("Fireball\nATK\nINT 12\n[Learn]"), detail.getString());
assertFalse(text(a.books.getLast()).contains("Cost:"));
assertFalse(text(a.books.getLast()).contains("Requires:"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nSTR 12, CON 12"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nATK\nSTR 12, CON 12"));
assertFalse(detail.getString().contains("Icon:"));
assertFalse(text(a.books.getLast()).contains("Assign ability"));
assertFalse(text(a.books.getLast()).contains("[3]"));
@@ -366,7 +400,29 @@ class CharacterBookNativeTest {
assertTrue(refreshed.get(2).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(2), "Fireball");
assertNotNull(activeJump);
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nATK\nINT 12\n[Unlearn]"));
}
}
@Test void mixedCategorySlotsAndUnknownIdsRemainUnrestrictedAndUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var mixed = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(),
java.util.Set.of("fireball", "quickstep", "second_wind", "unknown_saved"),
java.util.List.of("fireball", "quickstep", "second_wind", "unknown_saved", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(mixed, old.revision()); f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
var pages = nativePages(a); var active = pages.get(firstPageStarting(pages, "Active abilities"));
assertTrue(active.getString().contains("3: Fireball")); assertTrue(active.getString().contains("4: Quickstep"));
assertTrue(active.getString().contains("5: Second Wind")); assertTrue(active.getString().contains("6: unknown_saved"));
for (String ability : java.util.List.of("Fireball", "Quickstep", "Second Wind")) {
var jump = changePage(active, ability); assertNotNull(jump, ability);
assertTrue(pages.get(jump.page() - 1).getString().startsWith(ability + "\n"));
}
assertEquals(mixed, characters(f).find(mixed.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@@ -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);
@@ -904,6 +904,7 @@ 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",
"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);
@@ -1288,7 +1289,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")) {
var store = field(instance, name);
if (store != null) {
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
@@ -25,6 +25,7 @@ class FrostShardNativeTest {
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("FrostShard"), book); assertTrue(book.contains("INT12"), book);
assertTrue(book.contains("WIS10"), book); assertTrue(book.contains("SlownessI"), book);
assertTrue(book.contains("projectile-speed=3"), book);
var learn = abilityButton(a, "[Learn]", "frost_shard");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -41,17 +42,23 @@ class FrostShardNativeTest {
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
var velocity = new java.util.concurrent.atomic.AtomicReference<org.bukkit.util.Vector>();
doAnswer(call -> {
velocity.set(call.getArgument(1));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
}).when(a.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
verify(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
verify(a.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertEquals(new org.bukkit.util.Vector(3, 0, 0), velocity.get());
verify(projectile).setPickupStatus(org.bukkit.entity.AbstractArrow.PickupStatus.DISALLOWED);
verify(projectile).setDamage(0); verify(projectile).setCritical(false);
verify(projectile).setPierceLevel(0); verify(projectile).setKnockbackStrength(0);
var frost = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShard");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(frost, "projectiles")).size());
@@ -60,6 +67,9 @@ class FrostShardNativeTest {
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
when(target.addPotionEffect(any())).thenReturn(true);
var vanilla = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 9);
f.dispatcher.accept(vanilla); assertTrue(vanilla.isCancelled(), "Owned arrow native damage is contained");
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
@@ -184,13 +194,13 @@ class FrostShardNativeTest {
verify(projectile, atLeastOnce()).remove();
learnAndAssign(f, a); cast(f, a);
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); cast(f, b);
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
}
@@ -225,10 +235,22 @@ class FrostShardNativeTest {
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "frost_shard",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
verify(player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
verify(player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertEquals(1, failures.size()); engine.close(); frost.close(); parties.close();
}
@Test void retainedProjectileSpeedIsHonoredWithoutRewritingConfiguration() throws Exception {
String retained = "frost-shard-projectile-speed-blocks-per-tick: 1\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var definition = ((games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("frost_shard").orElseThrow();
assertEquals(0, new java.math.BigDecimal("1").compareTo(definition.parameters().get("projectile-speed")));
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
}
}
@Test void configuredDefinitionAndFormulaPreserveReviewedFiniteUncappedScaling() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"frost-shard-base-damage: 9\nfrost-shard-cooldown-seconds: 2.5\n"
@@ -286,7 +308,7 @@ class FrostShardNativeTest {
doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile)); return projectile; })
.when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
.when(a.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
@@ -458,14 +480,14 @@ class FrostShardNativeTest {
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.entity.Snowball... projectiles) {
org.bukkit.entity.Arrow... projectiles) {
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0)); doReturn(eye).when(actor.player).getEyeLocation();
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
doAnswer(call -> {
var projectile = queue.removeFirst(); fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(actor.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
}).when(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
@@ -518,9 +540,9 @@ class FrostShardNativeTest {
old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = mock(org.bukkit.entity.Snowball.class);
private static org.bukkit.entity.Arrow projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = mock(org.bukkit.entity.Arrow.class);
when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(projectile.getShooter()).thenReturn(actor.player);
when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
@@ -26,7 +26,7 @@ class GreatSwordNativeTest {
String book = PartyBookNativeTest.text(a);
assertTrue(book.contains("Great Sword"));
assertTrue(book.contains("STR 12"));
assertTrue(book.contains("1.2"));
assertTrue(book.contains("0.75"));
var learn = abilityButton(a, "[Learn]", "great_sword");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -68,8 +68,8 @@ class GreatSwordNativeTest {
var saved = storage.find(a.core.record.id()).get().orElseThrow();
var deadline = saved.cooldowns().get("great_sword");
assertNotNull(deadline);
assertFalse(deadline.isBefore(before.plusMillis(1200)));
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(1200)));
assertFalse(deadline.isBefore(before.plusMillis(750)));
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(750)));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -259,6 +259,54 @@ class GreatSwordNativeTest {
}
}
@Test void fractionalRecoveryRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-21T20:00:00Z"));
java.time.Clock clock = new java.time.Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var created = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(created.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12));
var initial = new games.dmg.fantasystick.persistence.CharacterRecord(created.id(), created.name(), created.revision(), stats,
created.level(), created.experience(), created.unspentPoints(), created.health(), java.util.Set.of("great_sword"),
java.util.List.of("great_sword", "", "", "", "", "", ""), created.cooldowns(), created.restrictedKills(), false, null, null);
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, clock)) {
storage.create(initial).get(5, java.util.concurrent.TimeUnit.SECONDS);
var current = new java.util.concurrent.atomic.AtomicReference<>(initial);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), initial.id());
when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenAnswer(ignored -> java.util.Optional.of(current.get()));
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
current.set(call.getArgument(2)); return true;
});
var player = mock(org.bukkit.entity.Player.class); var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> { hits.incrementAndGet(); return null; }).when(target)
.damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var sword = new games.dmg.fantasystick.ability.GreatSword(
games.dmg.fantasystick.ability.GreatSword.Settings.defaults());
var registry = new games.dmg.fantasystick.ability.AbilityRegistry(); registry.register(sword.definition());
var parties = new games.dmg.fantasystick.party.PartyRoster(clock, ignored -> true);
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties, clock,
Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failure -> fail(failure.toString()));
Runnable cast = () -> engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "great_sword",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
cast.run(); await(hits, 1);
var deadline = now.get().plusMillis(750); assertEquals(deadline, current.get().cooldowns().get("great_sword"));
cast.run(); assertEquals(1, hits.get());
now.set(deadline.minusNanos(1)); cast.run(); assertEquals(1, hits.get());
now.set(deadline); cast.run(); await(hits, 2);
engine.close(); sword.close(); parties.close();
}
}
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
@@ -312,6 +360,18 @@ class GreatSwordNativeTest {
}
}
@Test void retainedRecoveryIsHonoredWithoutRewritingConfiguration() throws Exception {
String retained = "great-sword-recovery-seconds: 1.2\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var definition = ((games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("great_sword").orElseThrow();
assertEquals(java.time.Duration.ofMillis(1200), definition.cooldown());
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
}
}
@Test void configuredDefinitionAndChargeCurvePreserveExactUncappedScaling() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"great-sword-base-damage: 9\ngreat-sword-recovery-seconds: 2.5\n");
@@ -389,6 +449,11 @@ class GreatSwordNativeTest {
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
}
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
@@ -40,7 +40,7 @@ class OutOfCombatRecoveryNativeTest {
@Test void exactDelayRepeatedIntervalsAndLargeClockJumpsPersistOnePulseBeforeNativePublication() {
var f = new Direct();
f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep();
assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep();
@@ -72,12 +72,12 @@ class OutOfCombatRecoveryNativeTest {
@Test void acceptedEnvironmentalAndDealtDamageResetWhileCancelledAndZeroAttemptsDoNot() {
var f = new Direct(); f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(29));
f.clock.advance(Duration.ofSeconds(9));
f.recovery.acceptedDamage(damage(f.player, 2));
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext(); assertEquals(5, f.health.get());
var noReset = new Direct(); noReset.recovery.sweep(); noReset.clock.advance(Duration.ofSeconds(29));
var noReset = new Direct(); noReset.recovery.sweep(); noReset.clock.advance(Duration.ofSeconds(9));
var cancelled = damage(noReset.player, 2); cancelled.setCancelled(true); noReset.recovery.acceptedDamage(cancelled);
noReset.recovery.acceptedDamage(damage(noReset.player, 0));
noReset.clock.advance(Duration.ofSeconds(1)); noReset.recovery.sweep(); noReset.runNext();
@@ -85,58 +85,58 @@ class OutOfCombatRecoveryNativeTest {
var dealt = new Direct();
var target = mock(Player.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
dealt.recovery.sweep(); dealt.clock.advance(Duration.ofSeconds(29));
dealt.recovery.sweep(); dealt.clock.advance(Duration.ofSeconds(9));
var source = DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(dealt.player).withDirectEntity(dealt.player).build();
dealt.recovery.acceptedDamage(new EntityDamageByEntityEvent(dealt.player, target,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 2));
dealt.clock.advance(Duration.ofSeconds(29)); dealt.recovery.sweep(); assertTrue(dealt.events.isEmpty());
dealt.clock.advance(Duration.ofSeconds(9)); dealt.recovery.sweep(); assertTrue(dealt.events.isEmpty());
dealt.clock.advance(Duration.ofSeconds(1)); dealt.recovery.sweep(); dealt.runNext();
assertEquals(5, dealt.health.get(), "Accepted outgoing damage starts a fresh delay for its source character");
}
@Test void cancellationFailureAndDamageDuringPersistenceNeverPublishOrCatchUp() {
var cancelled = new Direct(); cancelled.veto = true; cancelled.recovery.sweep();
cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(1, cancelled.events.size()); assertTrue(cancelled.callbacks.isEmpty()); assertEquals(4, cancelled.health.get());
cancelled.clock.advance(Duration.ofSeconds(9)); cancelled.recovery.sweep(); assertEquals(1, cancelled.events.size());
var failed = new Direct(); failed.automatic = false; failed.recovery.sweep(); failed.clock.advance(Duration.ofSeconds(30));
var failed = new Direct(); failed.automatic = false; failed.recovery.sweep(); failed.clock.advance(Duration.ofSeconds(10));
failed.recovery.sweep(); assertNotNull(failed.pending);
failed.pending.completeExceptionally(new java.io.IOException("simulated")); failed.runNext();
assertEquals(4, failed.health.get());
failed.clock.advance(Duration.ofSeconds(9)); failed.recovery.sweep();
assertEquals(1, failed.events.size(), "Failed persistence does not immediately replay a missed pulse");
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(30));
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(10));
stale.recovery.sweep();
stale.recovery.acceptedDamage(damage(stale.player, 1));
stale.pending.complete(Optional.of(stale.saved(BigDecimal.valueOf(5)))); stale.runNext();
assertEquals(4, stale.health.get(), "Damage during the save invalidates native publication");
stale.clock.advance(Duration.ofSeconds(29)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
stale.clock.advance(Duration.ofSeconds(9)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(30)); zero.recovery.sweep(); zero.runNext();
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(10)); zero.recovery.sweep(); zero.runNext();
assertEquals(5, zero.health.get(), "A zero passive bonus leaves the base pulse unchanged");
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); cancelled.veto = true;
var cancelledRevision = cancelled.record.get().revision();
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(4, cancelled.events.getFirst().getAmount());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());
var full = new Direct(); full.health.set(10d); full.record.set(full.saved(BigDecimal.TEN));
full.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); var fullRevision = full.record.get().revision();
full.recovery.sweep(); full.clock.advance(Duration.ofSeconds(30)); full.recovery.sweep();
full.recovery.sweep(); full.clock.advance(Duration.ofSeconds(10)); full.recovery.sweep();
assertTrue(full.events.isEmpty()); assertEquals(fullRevision, full.record.get().revision());
}
@Test void stackedRegenerationBonusProducesOneMaximumCappedNativeAndPersistedPulse() {
var f = new Direct(); f.health.set(9d); f.record.set(f.saved(BigDecimal.valueOf(9)));
f.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(30)); f.recovery.sweep();
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep();
assertEquals(1, f.events.size()); assertEquals(1, f.events.getFirst().getAmount());
f.runNext(); assertEquals(10, f.health.get()); assertEquals(0, BigDecimal.TEN.compareTo(f.record.get().health()));
}
@@ -151,6 +151,21 @@ class OutOfCombatRecoveryNativeTest {
assertEquals(5, f.health.get(), "The new delay evaluates from the retained activity timestamp");
}
@Test void retainedThirtySecondDelayRemainsEffectiveWithoutRewritingConfiguration() throws Exception {
String retained = "out-of-combat-healing-delay-seconds: 30\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = (games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecoverySettings");
assertEquals("30", settings.value(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.DELAY));
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("10", "30"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "out-of-combat-healing-delay-seconds", ""}));
}
}
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n");
@@ -35,6 +35,28 @@ class QuickerRegenerationNativeTest {
assertTrue(active > 0 && active < passive && passive < activeIndex && activeIndex < passiveIndex && passiveIndex < party);
assertTrue(pages.get(passive).getString().contains("1: No Passive"));
assertTrue(pages.get(passiveIndex).getString().replace("\n", "").contains("Quicker Regeneration I"));
var indexPageByPassive = new java.util.HashMap<String, Integer>();
var detailStartByPassive = new java.util.HashMap<String, Integer>();
var passiveRegistry = (games.dmg.fantasystick.passive.PassiveRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
for (var definition : passiveRegistry.definitions()) {
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) {
var target = changePageTo(pages.get(page), pages, definition.name());
if (target != null) { indexPageByPassive.put(definition.name(), page);
detailStartByPassive.put(definition.name(), target.page() - 1); break; }
}
}
assertEquals(passiveRegistry.definitions().size(), detailStartByPassive.size());
for (var entry : detailStartByPassive.entrySet()) {
int end = party;
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = entry.getValue(); candidate < end; candidate++)
if (changePageNamed(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
assertEquals(indexPageByPassive.get(entry.getKey()) + 1,
changePageNamed(pages.get(end - 1), "[Back to index]").page(), entry.getKey());
}
var jump = changePageTo(pages.get(passiveIndex), pages, "Quicker Regeneration I"); assertNotNull(jump);
String detail = pages.get(jump.page() - 1).getString();
assertTrue(detail.replaceAll("\\s+", "").startsWith("QuickerRegenerationICON12,WIS12[Learn]"), detail);
@@ -74,6 +96,50 @@ class QuickerRegenerationNativeTest {
}
}
@Test void multiPagePassiveIndexAndWrappedDetailsReturnToTheirExactContainingPage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.passive.PassiveRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
for (int index = 0; index < 12; index++) {
String suffix = String.format(java.util.Locale.ROOT, "%02d", index);
registry.register(new games.dmg.fantasystick.passive.PassiveDefinition("synthetic_" + suffix,
"Synthetic Passive " + suffix, java.util.Map.of(), java.util.Set.of(), BigInteger.ONE,
List.of(index == 0 ? "Wrapped detail ".repeat(80) : "Synthetic passive detail.")));
}
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
var before = storage(f).find(a.core.record.id()).get().orElseThrow(); PartyBookNativeTest.open(f, a);
var pages = nativePages(a); assertTrue(pages.size() <= 100);
int passiveIndex = first(pages, "Passive index"), party = first(pages, "Party");
var indexPageByPassive = new java.util.HashMap<String, Integer>();
var detailStartByPassive = new java.util.HashMap<String, Integer>();
int indexPages = 0;
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) indexPages++;
for (var definition : registry.definitions()) {
for (int page = passiveIndex; page < passiveIndex + indexPages; page++) {
var target = changePageTo(pages.get(page), pages, definition.name());
if (target != null) { indexPageByPassive.put(definition.name(), page);
detailStartByPassive.put(definition.name(), target.page() - 1); break; }
}
}
assertTrue(indexPages >= 2); assertEquals(registry.definitions().size(), detailStartByPassive.size());
boolean wrapped = false;
for (var entry : detailStartByPassive.entrySet()) {
int end = party;
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
wrapped |= end - entry.getValue() > 1;
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = entry.getValue(); candidate < end; candidate++)
if (changePageNamed(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
assertEquals(indexPageByPassive.get(entry.getKey()) + 1,
changePageNamed(pages.get(end - 1), "[Back to index]").page(), entry.getKey());
}
assertTrue(wrapped, "At least one passive detail must span native pages");
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
}
}
@Test void unknownFullPassiveSlotsRemainVisibleAndRefuseBeforeSpending() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -144,6 +210,15 @@ class QuickerRegenerationNativeTest {
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageNamed(
net.minecraft.network.chat.Component component, String label) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
for (var child : component.getSiblings()) {
var found = changePageNamed(child, label); if (found != null) return found;
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
for (var page : pages) { var found = custom(page, id); if (found != null) return found; }
throw new AssertionError("Missing custom action " + id);
@@ -32,7 +32,7 @@ class ShieldBashNativeTest {
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("3s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
@@ -197,6 +197,71 @@ class ShieldBashNativeTest {
}
}
@Test void packagedCooldownRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-21T20:00:00Z"));
java.time.Clock clock = new java.time.Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var created = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(created.stats()); stats.put(Stat.STR, BigInteger.valueOf(12)); stats.put(Stat.CON, BigInteger.valueOf(12));
var initial = new CharacterRecord(created.id(), created.name(), created.revision(), stats, created.level(), created.experience(),
created.unspentPoints(), created.health(), java.util.Set.of("shield_bash"),
List.of("shield_bash", "", "", "", "", "", ""), created.cooldowns(), created.restrictedKills(), false, null, null);
try (var storage = new CharacterStorage(repository, clock)) {
storage.create(initial).get(5, java.util.concurrent.TimeUnit.SECONDS);
var current = new java.util.concurrent.atomic.AtomicReference<>(initial);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), initial.id());
when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenAnswer(ignored -> java.util.Optional.of(current.get()));
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
current.set(call.getArgument(2)); return true;
});
var world = mock(org.bukkit.World.class); var player = mock(org.bukkit.entity.Player.class);
when(player.getUniqueId()).thenReturn(active.player()); doReturn(new Location(world, 0, 64, 0)).when(player).getLocation();
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new Location(world, 2, 64, 0)); doReturn(true).when(player).hasLineOfSight(target);
var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> { hits.incrementAndGet(); return null; }).when(target)
.damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var bash = new games.dmg.fantasystick.ability.ShieldBash(
games.dmg.fantasystick.ability.ShieldBash.Settings.defaults(), ignored -> {});
var registry = new games.dmg.fantasystick.ability.AbilityRegistry(); registry.register(bash.definition());
var parties = new games.dmg.fantasystick.party.PartyRoster(clock, ignored -> true);
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties, clock,
Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failure -> fail(failure.toString()));
Runnable cast = () -> engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "shield_bash",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
cast.run(); await(hits, 1);
var deadline = now.get().plusSeconds(3); assertEquals(deadline, current.get().cooldowns().get("shield_bash"));
cast.run(); assertEquals(1, hits.get());
now.set(deadline.minusNanos(1)); cast.run(); assertEquals(1, hits.get());
now.set(deadline); cast.run(); await(hits, 2);
engine.close(); bash.close(); parties.close();
}
}
@Test void retainedSixSecondCooldownIsHonoredWithoutRewritingConfiguration() throws Exception {
String retained = "shield-bash-cooldown-seconds: 6\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(java.time.Duration.ofSeconds(6), registry.find("shield_bash").orElseThrow().cooldown());
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("3", "6"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "shield-bash-cooldown-seconds", ""}));
}
}
@Test void exactDamageFormulaSupportsFiniteUncappedStats() {
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(3), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(9)));
@@ -291,6 +356,11 @@ class ShieldBashNativeTest {
}
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
@@ -49,10 +49,10 @@ class DistributionTest {
"The delivered configuration must expose the reviewed 15-minute default");
assertTrue(defaults.contains("adventure-mode: true"));
assertTrue(defaults.contains("great-sword-base-damage: 7"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 1.2"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 0.75"));
for (String setting : java.util.List.of("frost-shard-base-damage: 4",
"frost-shard-cooldown-seconds: 5", "frost-shard-range-blocks: 32",
"frost-shard-lifetime-seconds: 5", "frost-shard-projectile-speed-blocks-per-tick: 1",
"frost-shard-lifetime-seconds: 5", "frost-shard-projectile-speed-blocks-per-tick: 3",
"frost-shard-slowness-amplifier: 0", "frost-shard-slowness-duration-seconds: 2")) {
assertTrue(defaults.contains(setting), "Missing packaged Frost Shard default: " + setting);
}
@@ -74,7 +74,7 @@ class DistributionTest {
"stone-skin-duration-seconds: 4", "stone-skin-damage-reduction-percent: 25")) {
assertTrue(defaults.contains(setting), "Missing packaged Stone Skin default: " + setting);
}
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 6",
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 3",
"shield-bash-range-blocks: 3", "shield-bash-horizontal-knockback: 0.8",
"shield-bash-minimum-vertical-knockback: 0.35")) {
assertTrue(defaults.contains(setting), "Missing packaged Shield Bash default: " + setting);
@@ -104,7 +104,7 @@ class DistributionTest {
}
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 30",
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 10",
"out-of-combat-healing-interval-seconds: 10", "out-of-combat-healing-amount: 1",
"quicker-regeneration-i-bonus-healing: 1", "quicker-regeneration-ii-bonus-healing: 2",
"health-steal-healing-per-kill: 1")) {
@@ -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));
}
@@ -36,7 +36,9 @@ class FrostShardSettingsTest {
}
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
assertEquals("3", FrostShardSettings.defaultValue(FrostShardSettings.SPEED));
try (var settings = new FrostShardSettings(data, defaults())) {
assertEquals(3, settings.settings().speedPerTick());
for (var entry : Map.of(
FrostShardSettings.BASE_DAMAGE, "-1",
FrostShardSettings.COOLDOWN, "31536001",
@@ -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);
}
}
@@ -18,7 +18,7 @@ class OutOfCombatRecoverySettingsTest {
@Test void parsesReviewedWholeNumberBoundaries() {
var defaults = defaults();
var parsed = OutOfCombatRecoverySettings.parse(defaults);
assertEquals(Duration.ofSeconds(30), parsed.delay());
assertEquals(Duration.ofSeconds(10), parsed.delay());
assertEquals(Duration.ofSeconds(10), parsed.interval());
assertEquals(java.math.BigInteger.ONE, parsed.amount());
@@ -52,7 +52,7 @@ class OutOfCombatRecoverySettingsTest {
var delay = settings.set(OutOfCombatRecoverySettings.DELAY, "45").toCompletableFuture();
var amount = settings.set(OutOfCombatRecoverySettings.AMOUNT, "3").toCompletableFuture();
var d = delay.get(5, TimeUnit.SECONDS); var a = amount.get(5, TimeUnit.SECONDS);
assertEquals("30", settings.value(OutOfCombatRecoverySettings.DELAY));
assertEquals("10", settings.value(OutOfCombatRecoverySettings.DELAY));
settings.publish(a); settings.publish(d);
assertEquals("45", settings.value(OutOfCombatRecoverySettings.DELAY));
assertEquals("3", settings.value(OutOfCombatRecoverySettings.AMOUNT));
@@ -9,6 +9,10 @@ import org.junit.jupiter.api.io.TempDir;
class ShieldBashSettingsTest {
@TempDir Path data;
@Test void packagedDefaultCooldownIsThreeSeconds() {
assertEquals("3", ShieldBashSettings.defaultValue(ShieldBashSettings.COOLDOWN));
assertEquals(java.time.Duration.ofSeconds(3), ShieldBashSettings.parse(defaults()).cooldown());
}
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new ShieldBashSettings(data, defaults())) {
@@ -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)),