Compare commits

...
1 Commits
Author SHA1 Message Date
dmg 6d0d695052 feat(book): categorize active abilities
Release / release (push) Successful in 9m53s
2026-09-21 17:09:03 -04:00
25 changed files with 135 additions and 53 deletions

No files matched your search

@@ -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(),
@@ -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);
@@ -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,7 +159,6 @@ 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));
@@ -168,24 +168,33 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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();
var placeholderIndex = indexPages(definitions, placeholderTargets);
int indexCount = placeholderIndex.pages().size();
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).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));
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();
}
var index = indexPages(definitions, targets);
var details = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, indexTargets.get(definition.id()))).toList();
pages.addAll(activePages(record, definitions, targets));
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
pages.addAll(indexPages(definitions, targets));
pages.addAll(index.pages());
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
details.forEach(pages::addAll);
passiveDetails.forEach(pages::addAll);
@@ -274,17 +283,27 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return builder.pages();
}
private static java.util.List<Component> indexPages(
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 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 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()));
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();
for (int start = 0; start < grouped.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.text(category.name());
int relativePage = pages.size();
for (var definition : grouped.subList(start, Math.min(start + 11, grouped.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
relativePages.put(definition.id(), relativePage);
}
pages.addAll(builder.pages());
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
return new AbilityIndex(pages, relativePages);
}
private static java.util.List<Component> passiveIndexPages(
@@ -326,7 +345,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
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 +358,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 +371,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();
}
@@ -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());
}
}