Compare commits

...
4 Commits
Author SHA1 Message Date
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
33 changed files with 403 additions and 80 deletions

No files matched your search

@@ -1171,7 +1171,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(),
@@ -114,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(),
@@ -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();
}
@@ -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; }
+2 -2
View File
@@ -69,7 +69,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]
@@ -111,7 +111,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());
}
}
@@ -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());
}
}
@@ -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();
@@ -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,7 +49,7 @@ 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: 3",
@@ -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);
@@ -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())) {