feat(chain-lightning): split damage across twenty targets
CI / build (pull_request) Successful in 1m25s
CI / build (push) Successful in 2m0s

This commit is contained in:
dmg committed 2026-10-17 11:21:11 -04:00
1 parent 608a5fd2aa
commit edada37669
9 files changed
+317 -194

No files matched your search

@@ -255,7 +255,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return; return;
} }
CharacterRecord saved = mutation.record().orElseThrow(); CharacterRecord saved = mutation.record().orElseThrow();
boolean deferNativeCast = audit != null && definition.id().equals("quickstep"); boolean deferNativeCast = audit != null
&& Set.of("quickstep", "chain_lightning").contains(definition.id());
if (castEvents != null && !deferNativeCast) castEvents.cast(audit, saved, readyAt, definition); if (castEvents != null && !deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
boolean synchronizedProgress = unchangedDeadline boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent() ? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions; import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.BigInteger; import java.math.BigInteger;
import java.math.MathContext;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@@ -30,33 +31,36 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageEvent;
/** Deterministic INT-scaled bounded lightning chain with exact decimal decay. */ /** Deterministic bounded lightning chain sharing one exact INT-defined damage pool. */
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable { public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange, private static final BigInteger DAMAGE_FACTOR = BigInteger.valueOf(4);
int additionalTargets, BigDecimal jumpMultiplier, BigInteger targetIntStep, int maxTargets) { private static final MathContext SHARE_PRECISION = MathContext.DECIMAL128;
public record Settings(Duration cooldown, double initialRange, double jumpRange, int maxTargets) {
public Settings { public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier); Objects.requireNonNull(targetIntStep); Objects.requireNonNull(cooldown);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0 if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64 || !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64 || !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|| additionalTargets < 0 || additionalTargets > 63 || targetIntStep.signum() <= 0 || maxTargets < 1 || maxTargets > 64 || maxTargets < 1 || maxTargets > 20) {
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException("Invalid Chain Lightning settings"); throw new IllegalArgumentException("Invalid Chain Lightning settings");
} }
} }
public static Settings defaults() { public static Settings defaults() { return new Settings(Duration.ofSeconds(18), 16, 6, 20); }
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 4, new BigDecimal("0.75"), BigInteger.valueOf(5), 12);
}
} }
private record SelectedTarget(LivingEntity target, LivingEntity previous) { }
private static final class Attempt { private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; final double requested; final AbilityExecution execution; final LivingEntity target; final double requested;
final BigDecimal defined; final UUID chainId; final int ordinal; final BigDecimal totalDefined; final BigDecimal definedShare; final UUID chainId; final int ordinal;
final UUID previousId; final Map<String, Object> previousLocation; final int selectedChainSize; final UUID previousId; final Map<String, Object> previousLocation;
boolean accepted; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined, Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal totalDefined,
UUID chainId, int ordinal, LivingEntity previous) { BigDecimal definedShare, UUID chainId, int ordinal, int selectedChainSize, LivingEntity previous) {
this.execution = execution; this.target = target; this.requested = requested; this.execution = execution; this.target = target; this.requested = requested;
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal; this.totalDefined = totalDefined; this.definedShare = definedShare; this.chainId = chainId;
this.ordinal = ordinal; this.selectedChainSize = selectedChainSize;
previousId = previous == null ? null : previous.getUniqueId(); previousId = previous == null ? null : previous.getUniqueId();
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation()); previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
} }
@@ -82,36 +86,30 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
public AbilityDefinition definition() { public AbilityDefinition definition() {
var current = settings; var current = settings;
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD, return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, with " List.of("Build one damage pool equal to four times your purchased INT modifier.",
+ Math.min(current.maxTargets(), 1 + current.additionalTargets()) + " base total targets at INT 20.", "Discover up to " + current.maxTargets() + " targets, then divide that pool equally across the complete chain.",
"Gain one target per " + current.targetIntStep() + " purchased INT above 20, capped at " + current.maxTargets() + " total targets.", "Start within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks; each nearest visible jump is within "
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2)) + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."), "Use while aiming at the first visible hostile; cancelled or invalid shares are not redistributed.",
"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, AbilityDefinition.Category.ATK, 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(), AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), Map.of("initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(), "jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
"additional-targets", BigDecimal.valueOf(current.additionalTargets()), "max-targets", BigDecimal.valueOf(current.maxTargets()),
"jump-multiplier", current.jumpMultiplier(), "int-modifier-damage-factor", new BigDecimal(DAMAGE_FACTOR)), AbilityDefinition.TargetPolicy.SELECTION, this);
"target-int-step", new BigDecimal(current.targetIntStep()), "max-targets", BigDecimal.valueOf(current.maxTargets())), AbilityDefinition.TargetPolicy.SELECTION, this);
} }
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); } public static BigInteger intelligenceModifier(BigInteger intelligence) {
static BigInteger initialDamage(BigInteger base, BigInteger intelligence) { Objects.requireNonNull(intelligence);
Objects.requireNonNull(base); Objects.requireNonNull(intelligence); if (intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning INT");
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning damage input");
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO); 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); var modifier = divided[0];
return base.add(modifier); if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return modifier;
} }
public static int targetBudget(BigInteger intelligence, Settings captured) { public static BigInteger totalDefinedDamage(BigInteger intelligence) {
Objects.requireNonNull(intelligence); Objects.requireNonNull(captured); return intelligenceModifier(intelligence).multiply(DAMAGE_FACTOR);
var earned = intelligence.subtract(BigInteger.valueOf(20)).max(BigInteger.ZERO).divide(captured.targetIntStep());
return earned.add(BigInteger.valueOf(1L + captured.additionalTargets()))
.min(BigInteger.valueOf(captured.maxTargets())).intValueExact();
} }
@Override public boolean accepts(ControlInput input) { @Override public boolean accepts(ControlInput input) {
@@ -121,39 +119,76 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
@Override public void activate(AbilityExecution execution) { @Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; if (closed || !execution.stillCurrent()) return;
var captured = from(execution.definition()); var caster = execution.input().player(); var captured = from(execution.definition()); var caster = execution.input().player();
var purchasedInt = execution.character().stats().get(Stat.INT);
var initial = selectInitial(execution.input(), captured.initialRange()); var initial = selectInitial(execution.input(), captured.initialRange());
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; } if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null; var selected = discover(execution, caster, initial, captured);
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0; if (selected.isEmpty()) {
int maximum = targetBudget(execution.character().stats().get(Stat.INT), captured); execution.resolve(execution.stillCurrent() ? "miss" : "interrupted",
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) { Map.of("reason", execution.stillCurrent() ? "initial_target_became_ineligible" : "source_interrupted",
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break; "selected_chain_size", 0, "attempted_hops", 0, "accepted_hops", 0));
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous); return;
attempted++; if (attempt.accepted) accepted++;
visual(caster, current);
if (hop + 1 == maximum) break;
defined = defined.multiply(captured.jumpMultiplier());
previous = current;
current = next(caster, current, visited, captured.jumpRange());
} }
var outcome = new LinkedHashMap<String, Object>(); var totalDefined = new BigDecimal(totalDefinedDamage(purchasedInt));
outcome.put("chain_id", chainId.toString()); var definedShare = totalDefined.divide(BigDecimal.valueOf(selected.size()), SHARE_PRECISION);
double nativeShare = definedShare.doubleValue();
var chainId = UUID.randomUUID();
var common = new LinkedHashMap<String, Object>();
common.put("chain_id", chainId.toString());
common.put("selected_chain_size", selected.size());
common.put("total_defined_pool", decimal(totalDefined));
common.put("equal_defined_share", decimal(definedShare));
execution.castEvidence(common);
if (totalDefined.signum() <= 0 || !Double.isFinite(nativeShare) || nativeShare <= 0) {
common.put("terminal_reason", "native_damage_not_finite");
common.put("attempted_hops", 0); common.put("accepted_hops", 0);
execution.resolve("failed", common); return;
}
int attempted = 0, accepted = 0;
for (int index = 0; index < selected.size(); index++) {
var chosen = selected.get(index);
if (!execution.stillCurrent()) break;
var pathSource = chosen.previous() == null ? caster : chosen.previous();
double range = chosen.previous() == null ? captured.initialRange() : captured.jumpRange();
if (!eligible(caster, chosen.target()) || !inRangeAndVisible(pathSource, chosen.target(), range)) {
visual(caster, chosen.target()); continue;
}
var attempt = strike(execution, caster, chosen.target(), nativeShare, totalDefined, definedShare,
chainId, index + 1, selected.size(), chosen.previous());
attempted++; if (attempt.accepted) accepted++;
visual(caster, chosen.target());
}
var outcome = new LinkedHashMap<String, Object>(common);
outcome.put("initial_target_id", initial.getUniqueId().toString()); outcome.put("initial_target_id", initial.getUniqueId().toString());
outcome.put("initial_target_location", locationData(initial.getLocation())); outcome.put("initial_target_location", locationData(initial.getLocation()));
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted); outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted" outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
: attempted == maximum ? "target_limit" : "no_eligible_next_target"); : selected.size() == captured.maxTargets() ? "target_limit" : "no_eligible_next_target");
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome); execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
} }
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target, private List<SelectedTarget> discover(AbilityExecution execution, Player caster, LivingEntity initial, Settings captured) {
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) { var selected = new ArrayList<SelectedTarget>(); var visited = new HashSet<UUID>();
double amount = finite(definiteNonnegative(defined)); LivingEntity current = initial, previous = null;
while (current != null && selected.size() < captured.maxTargets() && execution.stillCurrent()) {
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
selected.add(new SelectedTarget(current, previous));
if (selected.size() == captured.maxTargets()) break;
previous = current; current = next(caster, current, visited, captured.jumpRange());
}
return List.copyOf(selected);
}
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target, double amount,
BigDecimal totalDefined, BigDecimal definedShare, UUID chainId, int ordinal,
int selectedChainSize, LivingEntity previous) {
var origin = caster.getEyeLocation().clone(); var origin = caster.getEyeLocation().clone();
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster) var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.withDirectEntity(caster).withDamageLocation(origin).build(); .withDirectEntity(caster).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt; var attempt = new Attempt(execution, target, amount, totalDefined, definedShare, chainId, ordinal,
selectedChainSize, previous); damaging = attempt;
try { execution.damage(target, amount, source); } try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; } finally { if (damaging == attempt) damaging = null; }
return attempt; return attempt;
@@ -190,6 +225,8 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
private boolean eligible(Player caster, LivingEntity target) { private boolean eligible(Player caster, LivingEntity target) {
if (target == caster) return false; if (target == caster) return false;
try { if (target.isDead()) return false; }
catch (RuntimeException unavailable) { return false; }
if (!(target instanceof Player player)) return true; if (!(target instanceof Player player)) return true;
if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false; if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false;
var sourceParty = parties.partyOf(caster.getUniqueId()).orElse(null); var sourceParty = parties.partyOf(caster.getUniqueId()).orElse(null);
@@ -218,8 +255,12 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
var evidence = new LinkedHashMap<String, Object>(); var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_kind", "owned_chain_strike"); evidence.put("damage_kind", "owned_chain_strike");
evidence.put("chain_id", attempt.chainId.toString()); evidence.put("chain_id", attempt.chainId.toString());
evidence.put("selected_chain_size", attempt.selectedChainSize);
evidence.put("total_defined_pool", decimal(attempt.totalDefined));
evidence.put("equal_defined_share", decimal(attempt.definedShare));
evidence.put("hop_ordinal", attempt.ordinal); evidence.put("hop_ordinal", attempt.ordinal);
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString()); evidence.put("defined_amount", decimal(attempt.definedShare));
evidence.put("current_target_id", attempt.target.getUniqueId().toString());
if (attempt.previousId != null) { if (attempt.previousId != null) {
evidence.put("previous_target_id", attempt.previousId.toString()); evidence.put("previous_target_id", attempt.previousId.toString());
evidence.put("previous_target_location", attempt.previousLocation); evidence.put("previous_target_location", attempt.previousLocation);
@@ -255,16 +296,10 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f); caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
} catch (RuntimeException ignored) { } } catch (RuntimeException ignored) { }
} }
private static BigDecimal definiteNonnegative(BigDecimal value) { return value.signum() < 0 ? BigDecimal.ZERO : value; }
private static double finite(BigDecimal value) {
if (value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0) return Double.MAX_VALUE;
double result = value.doubleValue(); return Double.isFinite(result) ? result : Double.MAX_VALUE;
}
private static Settings from(AbilityDefinition definition) { private static Settings from(AbilityDefinition definition) {
var p = definition.parameters(); var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("initial-range").doubleValue(), return new Settings(definition.cooldown(), p.get("initial-range").doubleValue(),
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"), p.get("jump-range").doubleValue(), p.get("max-targets").intValueExact());
p.get("target-int-step").toBigIntegerExact(), p.get("max-targets").intValueExact());
} }
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); } private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() { closed = true; damaging = null; } @Override public void close() { closed = true; damaging = null; }
@@ -94,6 +94,11 @@ public final class AbilityCastEventBridge {
"native_velocity_status")) "native_velocity_status"))
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key)); if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
} }
if (cast.abilityId().equals("chain_lightning")) {
for (String key : java.util.List.of("chain_id", "selected_chain_size", "total_defined_pool",
"equal_defined_share"))
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
}
if (cast.abilityId().equals("longbow")) { if (cast.abilityId().equals("longbow")) {
data.put("draw_ticks", cast.heldTicks()); data.put("draw_ticks", cast.heldTicks());
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(), data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
@@ -28,17 +28,13 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Chain Lightning settings; publication remains a server-thread caller decision. */ /** Serialized durable Chain Lightning settings; publication remains a server-thread caller decision. */
public final class ChainLightningSettings implements AutoCloseable { public final class ChainLightningSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576; private static final int MAX_BYTES = 1_048_576;
public static final String BASE = "chain-lightning-base-damage";
public static final String COOLDOWN = "chain-lightning-cooldown-seconds"; public static final String COOLDOWN = "chain-lightning-cooldown-seconds";
public static final String INITIAL_RANGE = "chain-lightning-initial-range-blocks"; public static final String INITIAL_RANGE = "chain-lightning-initial-range-blocks";
public static final String JUMP_RANGE = "chain-lightning-jump-range-blocks"; public static final String JUMP_RANGE = "chain-lightning-jump-range-blocks";
public static final String ADDITIONAL = "chain-lightning-additional-targets";
public static final String MULTIPLIER = "chain-lightning-jump-multiplier";
public static final String INT_STEP = "chain-lightning-target-int-step";
public static final String MAX_TARGETS = "chain-lightning-max-targets"; public static final String MAX_TARGETS = "chain-lightning-max-targets";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER, INT_STEP, MAX_TARGETS); private static final List<String> KEYS = List.of(COOLDOWN, INITIAL_RANGE, JUMP_RANGE, MAX_TARGETS);
private static final Map<String, String> DEFAULTS = Map.of( private static final Map<String, String> DEFAULTS = Map.of(
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "4", MULTIPLIER, "0.75", INT_STEP, "5", MAX_TARGETS, "12"); COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", MAX_TARGETS, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; } @FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, ChainLightning.Settings settings, Map<String, String> values, long sequence) { public record Update(String key, String value, ChainLightning.Settings settings, Map<String, String> values, long sequence) {
@@ -112,10 +108,9 @@ public final class ChainLightningSettings implements AutoCloseable {
} }
public static ChainLightning.Settings parse(Map<String, String> values) { public static ChainLightning.Settings parse(Map<String, String> values) {
try { return new ChainLightning.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(), try { return new ChainLightning.Settings(seconds(values.get(COOLDOWN)),
seconds(values.get(COOLDOWN)), finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)), finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
new BigDecimal(values.get(ADDITIONAL)).intValueExact(), new BigDecimal(values.get(MULTIPLIER)), new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
new BigDecimal(values.get(INT_STEP)).toBigIntegerExact(), new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) { catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid); throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid);
} }
+3 -6
View File
@@ -212,16 +212,13 @@ frost-nova-radius-blocks: 5
frost-nova-slowness-amplifier: 1 frost-nova-slowness-amplifier: 1
frost-nova-slowness-duration-seconds: 3 frost-nova-slowness-duration-seconds: 3
# Chain Lightning defaults from the reviewed remaining-development packet. # Chain Lightning effective settings. Damage is always four times the purchased-INT modifier,
# divided equally across the discovered chain. Obsolete damage/progression keys are ignored.
# Admin command: /fstickadmin config <key> [value] # Admin command: /fstickadmin config <key> [value]
chain-lightning-base-damage: 8
chain-lightning-cooldown-seconds: 18 chain-lightning-cooldown-seconds: 18
chain-lightning-initial-range-blocks: 16 chain-lightning-initial-range-blocks: 16
chain-lightning-jump-range-blocks: 6 chain-lightning-jump-range-blocks: 6
chain-lightning-additional-targets: 4 chain-lightning-max-targets: 20
chain-lightning-jump-multiplier: 0.75
chain-lightning-target-int-step: 5
chain-lightning-max-targets: 12
# Sanctuary defaults from the reviewed remaining-development packet. # Sanctuary defaults from the reviewed remaining-development packet.
# Admin command: /fstickadmin config <key> [value] # Admin command: /fstickadmin config <key> [value]
@@ -166,7 +166,7 @@ class AdminActionConfigurationCatalogNativeTest {
new String[]{"hex-duration-seconds", "5"}, new String[]{"inspire-base-temporary-health", "5"}, new String[]{"hex-duration-seconds", "5"}, new String[]{"inspire-base-temporary-health", "5"},
new String[]{"mending-light-base-healing", "7"}, new String[]{"purify-range-blocks", "13"}, new String[]{"mending-light-base-healing", "7"}, new String[]{"purify-range-blocks", "13"},
new String[]{"guardian-bond-range-blocks", "11"}, new String[]{"rallying-cry-radius-blocks", "9"}, new String[]{"guardian-bond-range-blocks", "11"}, new String[]{"rallying-cry-radius-blocks", "9"},
new String[]{"frost-nova-radius-blocks", "6"}, new String[]{"chain-lightning-base-damage", "9"}, new String[]{"frost-nova-radius-blocks", "6"}, new String[]{"chain-lightning-max-targets", "19"},
new String[]{"sanctuary-radius-blocks", "7"}, new String[]{"war-banner-radius-blocks", "9"}, new String[]{"sanctuary-radius-blocks", "7"}, new String[]{"war-banner-radius-blocks", "9"},
new String[]{"meteor-base-damage", "15"}); new String[]{"meteor-base-damage", "15"});
@@ -43,17 +43,13 @@ class ChainLightningCoverageNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
static Stream<Arguments> coverage() { static Stream<Arguments> coverage() {
return Stream.of( return Stream.of(
Arguments.of("20", "", 5, false), Arguments.of("24", "", 5, false), Arguments.of("20", "chain-lightning-max-targets: 1\n", 1, false),
Arguments.of("25", "", 6, false), Arguments.of("29", "", 6, false), Arguments.of("20", "chain-lightning-max-targets: 3\n", 3, false),
Arguments.of("30", "", 7, false), Arguments.of("34", "", 7, false), Arguments.of("20", "", 20, true),
Arguments.of("35", "", 8, false), Arguments.of("39", "", 8, false), Arguments.of("30", "chain-lightning-max-targets: 5\n", 5, false),
Arguments.of("54", "", 11, false), Arguments.of("55", "", 12, true), Arguments.of("30", "chain-lightning-max-targets: 2\n", 2, false),
Arguments.of(BigInteger.TEN.pow(500).toString(), "", 12, false), Arguments.of("20", "chain-lightning-base-damage: 999\nchain-lightning-additional-targets: 0\n"
Arguments.of("30", "chain-lightning-additional-targets: 3\n", 6, false), + "chain-lightning-jump-multiplier: 0.01\nchain-lightning-target-int-step: 1\n", 20, false));
Arguments.of("30", "chain-lightning-target-int-step: 2\n", 10, false),
Arguments.of("55", "chain-lightning-max-targets: 2\n", 2, false),
Arguments.of("25", "chain-lightning-additional-targets: 0\n", 2, false),
Arguments.of("20", "chain-lightning-additional-targets: 63\nchain-lightning-max-targets: 64\n", 64, false));
} }
@ParameterizedTest @MethodSource("coverage") @ParameterizedTest @MethodSource("coverage")
void purchasedIntAndCapturedSettingsProduceActualUniqueHopsAndTruthfulDamage( void purchasedIntAndCapturedSettingsProduceActualUniqueHopsAndTruthfulDamage(
@@ -61,7 +57,7 @@ class ChainLightningCoverageNativeTest {
exercise(purchasedInt, retained, expected, cancelThird, false); exercise(purchasedInt, retained, expected, cancelThird, false);
} }
@Test void pendingCastKeepsCapturedPolicyWhileAcknowledgedAdminUpdateAffectsOnlyNextCast() throws Exception { @Test void pendingCastKeepsCapturedPolicyWhileAcknowledgedAdminUpdateAffectsOnlyNextCast() throws Exception {
exercise("55", "", 12, false, true); exercise("30", "", 20, false, true);
} }
private void exercise(String purchasedInt, String retained, int expected, boolean cancelThird, boolean changeDuringSave) throws Exception { private void exercise(String purchasedInt, String retained, int expected, boolean cancelThird, boolean changeDuringSave) throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-cooldown-seconds: 0\n" + retained); Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-cooldown-seconds: 0\n" + retained);
@@ -74,7 +70,7 @@ class ChainLightningCoverageNativeTest {
var capturedDefinition = ((AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("chain_lightning").orElseThrow(); var capturedDefinition = ((AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("chain_lightning").orElseThrow();
PartyBookNativeTest.click(f, caster, learn(caster)); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.click(f, caster, learn(caster)); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var targets = new ArrayList<LivingEntity>(); var targets = new ArrayList<LivingEntity>();
for (int index = 0; index < Math.max(13, expected + 1); index++) { for (int index = 0; index < Math.max(21, expected + 1); index++) {
var target = f.own(mock(LivingEntity.class)); targets.add(target); var target = f.own(mock(LivingEntity.class)); targets.add(target);
when(target.getUniqueId()).thenReturn(new UUID(0, index + 1)); when(target.getName()).thenReturn("Coverage " + index); when(target.getUniqueId()).thenReturn(new UUID(0, index + 1)); when(target.getName()).thenReturn("Coverage " + index);
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.HUSK); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.HUSK);
@@ -132,11 +128,14 @@ class ChainLightningCoverageNativeTest {
assertEquals(expected - (cancelThird ? 1 : 0), applied.size()); assertEquals(expected - (cancelThird ? 1 : 0), applied.size());
verify(targets.get(expected), never()).damage(anyDouble(), any(DamageSource.class)); verify(targets.get(expected), never()).damage(anyDouble(), any(DamageSource.class));
var parameters = capturedDefinition.parameters(); var parameters = capturedDefinition.parameters();
int base = Math.min(parameters.get("max-targets").intValueExact(), 1 + parameters.get("additional-targets").intValueExact()); assertEquals(expected, parameters.get("max-targets").intValueExact());
// BookPagination wraps by glyph width, including within words; preserve all actual text/numbers. assertEquals(new BigDecimal("4"), parameters.get("int-modifier-damage-factor"));
assertTrue(book.contains(base + "basetotaltargetsatINT20"), "book must explain the actual captured base"); assertFalse(parameters.keySet().stream().anyMatch(key -> List.of("base-damage", "additional-targets",
assertTrue(book.contains("per" + parameters.get("target-int-step").toPlainString() + "purchasedINTabove20")); "jump-multiplier", "target-int-step").contains(key)));
assertTrue(book.contains("cappedat" + parameters.get("max-targets").toPlainString() + "totaltargets")); // BookPagination wraps by glyph width, including within words; preserve the effective formula and ceiling.
assertTrue(book.contains("fourtimesyourpurchasedINTmodifier"));
assertTrue(book.contains("divide thatpoolequally".replace(" ", "")));
assertTrue(book.contains("upto" + expected + "targets"));
var saved = new YamlCharacterRepository(data).load(caster.core.record.id()).orElseThrow(); var saved = new YamlCharacterRepository(data).load(caster.core.record.id()).orElseThrow();
assertEquals(BigInteger.valueOf(39), saved.unspentPoints()); assertTrue(saved.learned().contains("chain_lightning")); assertEquals(BigInteger.valueOf(39), saved.unspentPoints()); assertTrue(saved.learned().contains("chain_lightning"));
assertEquals("chain_lightning", saved.loadout().getFirst()); assertNotNull(saved.cooldowns().get("chain_lightning")); assertEquals("chain_lightning", saved.loadout().getFirst()); assertNotNull(saved.cooldowns().get("chain_lightning"));
@@ -145,17 +144,28 @@ class ChainLightningCoverageNativeTest {
catch (Exception failure) { throw new AssertionError(failure); } catch (Exception failure) { throw new AssertionError(failure); }
}); });
var hits = rows("combat.damage"); assertEquals(applied.size(), hits.size()); var hits = rows("combat.damage"); assertEquals(applied.size(), hits.size());
var total = new BigDecimal(ChainLightning.totalDefinedDamage(new BigInteger(purchasedInt)));
var share = total.divide(BigDecimal.valueOf(expected), java.math.MathContext.DECIMAL128);
assertTrue(attempted.values().stream().allMatch(value -> Math.abs(value - share.doubleValue()) < 1e-9));
for (var row : hits) { for (var row : hits) {
var facts = row.getAsJsonObject("data"); int ordinal = facts.get("hop_ordinal").getAsInt(); var facts = row.getAsJsonObject("data"); int ordinal = facts.get("hop_ordinal").getAsInt();
var defined = new BigDecimal(ChainLightning.initialDamage(new BigInteger(purchasedInt))) assertEquals(total.stripTrailingZeros().toPlainString(), facts.get("total_defined_pool").getAsString());
.multiply(new BigDecimal("0.75").pow(ordinal - 1)); assertEquals(share.stripTrailingZeros().toPlainString(), facts.get("equal_defined_share").getAsString());
assertEquals(defined.stripTrailingZeros().toPlainString(), facts.get("defined_amount").getAsString()); assertEquals(share.stripTrailingZeros().toPlainString(), facts.get("defined_amount").getAsString());
assertEquals(expected, facts.get("selected_chain_size").getAsInt());
assertEquals(targets.get(ordinal - 1).getUniqueId().toString(), facts.get("current_target_id").getAsString());
assertEquals(applied.get(targets.get(ordinal - 1).getUniqueId()), facts.get("actual_amount").getAsDouble(), 1e-9); assertEquals(applied.get(targets.get(ordinal - 1).getUniqueId()), facts.get("actual_amount").getAsDouble(), 1e-9);
assertTrue(facts.has("target_location")); if (ordinal > 1) assertTrue(facts.has("previous_target_id")); assertTrue(facts.has("target_location")); if (ordinal > 1) assertTrue(facts.has("previous_target_id"));
if (cancelThird) assertNotEquals(3, ordinal); if (cancelThird) assertNotEquals(3, ordinal);
} }
assertEquals(hits.size(), hits.stream().map(row -> row.getAsJsonObject("data").get("application_id").getAsString()).distinct().count()); assertEquals(hits.size(), hits.stream().map(row -> row.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
var cast = rows("ability.cast").getFirst().getAsJsonObject("data");
var resolved = rows("ability.resolved").getFirst().getAsJsonObject("data"); var resolved = rows("ability.resolved").getFirst().getAsJsonObject("data");
for (var evidence : List.of(cast, resolved)) {
assertEquals(expected, evidence.get("selected_chain_size").getAsInt());
assertEquals(total.stripTrailingZeros().toPlainString(), evidence.get("total_defined_pool").getAsString());
assertEquals(share.stripTrailingZeros().toPlainString(), evidence.get("equal_defined_share").getAsString());
}
assertEquals(expected, resolved.get("attempted_hops").getAsInt()); assertEquals(applied.size(), resolved.get("accepted_hops").getAsInt()); assertEquals(expected, resolved.get("attempted_hops").getAsInt()); assertEquals(applied.size(), resolved.get("accepted_hops").getAsInt());
assertEquals(1, rows("ability.cast").size(), "duplicate pending input never starts another business cast"); assertEquals(1, rows("ability.cast").size(), "duplicate pending input never starts another business cast");
if (changeDuringSave) { if (changeDuringSave) {
@@ -175,47 +185,55 @@ class ChainLightningCoverageNativeTest {
} }
} }
} }
@Test void newConfigurationKeysUseRegisteredPermissionCheckedDurableReadUpdateCompletionAndReload() throws Exception { @Test void effectiveConfigurationUsesRegisteredDurableSurfaceAndPreservesIgnoredLegacyKeys() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-additional-targets: 3\nunrelated: retained\n"); Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-base-damage: 999\n"
+ "chain-lightning-additional-targets: 3\nchain-lightning-jump-multiplier: 0.25\n"
+ "chain-lightning-target-int-step: 1\nunrelated: retained\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false; f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}).isEmpty()); assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}).isEmpty());
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", "7"}); f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true; assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
var completions = f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}); var completions = f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"});
assertTrue(completions.containsAll(List.of("chain-lightning-target-int-step", "chain-lightning-max-targets")), "new keys must be wired through real command completion"); assertEquals(List.of(ChainLightningSettings.COOLDOWN, ChainLightningSettings.INITIAL_RANGE,
ChainLightningSettings.JUMP_RANGE, ChainLightningSettings.MAX_TARGETS), completions);
assertTrue(completions.stream().noneMatch(key -> List.of("chain-lightning-base-damage",
"chain-lightning-additional-targets", "chain-lightning-jump-multiplier",
"chain-lightning-target-int-step").contains(key)));
var store = (ChainLightningSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "chainLightningSettings"); var store = (ChainLightningSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "chainLightningSettings");
assertEquals("3", store.value(ChainLightningSettings.ADDITIONAL), "retained explicit base is never silently replaced"); assertEquals("20", store.value(ChainLightningSettings.MAX_TARGETS));
for (var entry : Map.of("chain-lightning-target-int-step", "7", "chain-lightning-max-targets", "2").entrySet()) { f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2"});
f.command.execute(sender.port, "fstickadmin", new String[]{"config", entry.getKey(), entry.getValue()}); var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run(); assertEquals("2", store.value(ChainLightningSettings.MAX_TARGETS));
assertEquals(entry.getValue(), store.value(entry.getKey())); assertEquals(List.of("2", "20"), f.command.tabComplete(sender.port, "fstickadmin",
} new String[]{"config", ChainLightningSettings.MAX_TARGETS, ""}));
assertEquals(List.of("5", "7"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", ""})); assertTrue(f.command.tabComplete(sender.port, "fstickadmin",
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-max-targets", "2", "extra"}).isEmpty()); new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2", "extra"}).isEmpty());
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step"}); f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS});
assertTrue(sender.messages.getLast().contains("=7")); assertTrue(sender.messages.getLast().contains("=2"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE); try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) { var lock = channel.lock()) {
assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", "1"}); assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin",
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run(); new String[]{"config", ChainLightningSettings.MAX_TARGETS, "1"});
callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
} }
assertEquals("7", store.value("chain-lightning-target-int-step")); assertTrue(sender.messages.getLast().contains("save failed")); assertEquals("2", store.value(ChainLightningSettings.MAX_TARGETS)); assertTrue(sender.messages.getLast().contains("save failed"));
for (String[] invalid : List.of(new String[]{"chain-lightning-target-int-step", "0"}, new String[]{"chain-lightning-target-int-step", "1.5"}, for (String invalid : List.of("0", "21", "1.5")) {
new String[]{"chain-lightning-max-targets", "0"}, new String[]{"chain-lightning-max-targets", "65"})) { f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, invalid});
f.command.execute(sender.port, "fstickadmin", new String[]{"config", invalid[0], invalid[1]}); assertTrue(sender.messages.getLast().contains("invalid")); assertTrue(sender.messages.getLast().contains("invalid"));
} }
assertTrue(Files.readString(data.resolve("config.yml")).contains("retained")); String savedFile = Files.readString(data.resolve("config.yml"));
assertTrue(savedFile.contains("retained")); assertTrue(savedFile.contains("chain-lightning-base-damage"));
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new BigDecimal("7"), registry.find("chain_lightning").orElseThrow().parameters().get("target-int-step")); var parameters = registry.find("chain_lightning").orElseThrow().parameters();
assertEquals(new BigDecimal("2"), registry.find("chain_lightning").orElseThrow().parameters().get("max-targets")); assertEquals(new BigDecimal("2"), parameters.get("max-targets"));
assertFalse(parameters.containsKey("base-damage")); assertFalse(parameters.containsKey("target-int-step"));
ReskillEventsNativeTest.await(f, () -> { ReskillEventsNativeTest.await(f, () -> {
try { return configRows().size() == 3; } catch (Exception failure) { throw new AssertionError(failure); } try { return configRows().size() == 2; } catch (Exception failure) { throw new AssertionError(failure); }
}); });
f.plugin.setEnabled(false); f.plugin.setEnabled(true); f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var restarted = (ChainLightningSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "chainLightningSettings"); var restarted = (ChainLightningSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "chainLightningSettings");
assertEquals("7", restarted.value("chain-lightning-target-int-step")); assertEquals("2", restarted.value("chain-lightning-max-targets")); assertEquals("2", restarted.value(ChainLightningSettings.MAX_TARGETS));
assertEquals("3", restarted.value(ChainLightningSettings.ADDITIONAL));
} }
} }
private void seed(FantasyStickLifecycleNativeTest.Actor actor, BigInteger intelligence) throws Exception { private void seed(FantasyStickLifecycleNativeTest.Actor actor, BigInteger intelligence) throws Exception {
@@ -240,6 +258,7 @@ class ChainLightningCoverageNativeTest {
} }
private List<com.google.gson.JsonObject> configRows() throws Exception { private List<com.google.gson.JsonObject> configRows() throws Exception {
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("config.read") || row.get("type").getAsString().endsWith("config.changed")) return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("config.read") || row.get("type").getAsString().endsWith("config.changed"))
.filter(row -> row.getAsJsonObject("data").has("key") && List.of("chain-lightning-target-int-step", "chain-lightning-max-targets").contains(row.getAsJsonObject("data").get("key").getAsString())).toList(); .filter(row -> row.getAsJsonObject("data").has("key")
&& ChainLightningSettings.MAX_TARGETS.equals(row.getAsJsonObject("data").get("key").getAsString())).toList();
} }
} }
@@ -59,9 +59,9 @@ class ChainLightningNativeTest {
assertEquals(2, viewer.particleCalls.stream().filter(call -> call[0] == org.bukkit.Particle.ELECTRIC_SPARK assertEquals(2, viewer.particleCalls.stream().filter(call -> call[0] == org.bukkit.Particle.ELECTRIC_SPARK
&& call[2].equals(18)).count(), "An enabled nearby viewer retains every chain burst"); && call[2].equals(18)).count(), "An enabled nearby viewer retains every chain burst");
assertFalse(damage.isEmpty(), caster.messages.toString()); assertFalse(damage.isEmpty(), caster.messages.toString());
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10); assertEquals(10, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(10, damage.get(second.getUniqueId()), 1.0e-10);
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to Zombie."::equals).count()); assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Zombie."::equals).count());
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Skeleton."::equals).count()); assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Skeleton."::equals).count());
var stored = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")) var stored = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
.find(caster.core.record.id()).get().orElseThrow(); .find(caster.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning")); assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
@@ -76,8 +76,10 @@ class ChainLightningNativeTest {
"The generic native observer must not repeat feature-owned chain hits"); "The generic native observer must not repeat feature-owned chain hits");
assertEquals(List.of(1, 2), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList()); assertEquals(List.of(1, 2), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
assertEquals(2, hits.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count()); assertEquals(2, hits.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertEquals(13, hits.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9); assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("actual_amount").getAsDouble() == 10));
assertEquals(9.75, hits.getLast().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9); assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("selected_chain_size").getAsInt() == 2));
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("total_defined_pool").getAsString().equals("20")));
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("equal_defined_share").getAsString().equals("10")));
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location"))); assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")));
assertEquals("hit", abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("outcome").getAsString()); assertEquals("hit", abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("outcome").getAsString());
} }
@@ -127,7 +129,8 @@ class ChainLightningNativeTest {
} }
var records = events(data); var hits = abilityEvents(records, "combat.damage"); var records = events(data); var hits = abilityEvents(records, "combat.damage");
assertEquals(List.of(1, 3), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList()); assertEquals(List.of(1, 3), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
assertEquals("7.3125", hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString()); assertEquals(new BigDecimal("20").divide(BigDecimal.valueOf(3), java.math.MathContext.DECIMAL128).toPlainString(),
hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
assertEquals(3, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("attempted_hops").getAsInt()); assertEquals(3, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("attempted_hops").getAsInt());
assertEquals(2, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("accepted_hops").getAsInt()); assertEquals(2, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("accepted_hops").getAsInt());
} }
@@ -170,12 +173,12 @@ class ChainLightningNativeTest {
}).when(target).damage(anyDouble(), any(DamageSource.class)); }).when(target).damage(anyDouble(), any(DamageSource.class));
use(fixture, caster); drain(fixture, 2); use(fixture, caster); drain(fixture, 2);
assertEquals(2, events.size()); assertTrue(events.stream().allMatch(EntityDamageEvent::isCancelled)); assertEquals(2, events.size()); assertTrue(events.stream().allMatch(EntityDamageEvent::isCancelled));
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to First Target."::equals).count(), caster.messages::toString); assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to First Target."::equals).count(), caster.messages::toString);
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Second Target."::equals).count()); assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Second Target."::equals).count());
} }
} }
@Test void exactChainUsesNearestUuidTiesFiveTargetLimitDecayAndCancellationContinuation() { @Test void completeDeterministicChainPrecedesOneEqualFractionalDamageSharePerSelectedTarget() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class); var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
var world = mock(org.bukkit.World.class); UUID casterId = UUID.randomUUID(); when(caster.getUniqueId()).thenReturn(casterId); var world = mock(org.bukkit.World.class); UUID casterId = UUID.randomUUID(); when(caster.getUniqueId()).thenReturn(casterId);
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0)); when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
@@ -198,10 +201,69 @@ class ChainLightningNativeTest {
(DamageSource) call.getArgument(1), amount)); return null; (DamageSource) call.getArgument(1), amount)); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class)); } }).when(target).damage(anyDouble(), any(DamageSource.class)); }
chain.activate(execution); chain.activate(execution);
assertEquals(targets.subList(0, 5).stream().map(LivingEntity::getUniqueId).toList(), List.copyOf(attempted.keySet())); assertEquals(targets.stream().map(LivingEntity::getUniqueId).toList(), List.copyOf(attempted.keySet()));
assertArrayEquals(new double[]{13, 9.75, 7.3125, 5.484375, 4.11328125}, attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10); assertArrayEquals(new double[]{20d / 6, 20d / 6, 20d / 6, 20d / 6, 20d / 6, 20d / 6},
verify(targets.get(5), never()).damage(anyDouble(), any(DamageSource.class)); attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
verify(execution, times(4)).report(eq(AbilityReport.Type.DAMAGE), any(), any()); var captureAndDiscovery = new ArrayList<Object>(); captureAndDiscovery.add(execution); captureAndDiscovery.addAll(targets);
var discovery = inOrder(captureAndDiscovery.toArray());
discovery.verify(execution).character();
for (var target : targets) discovery.verify(target).getNearbyEntities(6, 6, 6);
discovery.verify(targets.getFirst()).damage(anyDouble(), any(DamageSource.class));
verify(execution, times(5)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
}
@Test void newlyInvalidReservedShareIsNotRedistributedOrReplayed() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
var first = mock(LivingEntity.class); var invalidated = mock(LivingEntity.class); var third = mock(LivingEntity.class);
locate(first, world, new UUID(0, 1), 10); locate(invalidated, world, new UUID(0, 2), 14); locate(third, world, new UUID(0, 3), 18);
when(caster.hasLineOfSight(first)).thenReturn(true); when(first.hasLineOfSight(invalidated)).thenReturn(true);
when(invalidated.hasLineOfSight(third)).thenReturn(true);
when(first.getNearbyEntities(6, 6, 6)).thenReturn(List.of(invalidated));
when(invalidated.getNearbyEntities(6, 6, 6)).thenReturn(List.of(third));
var becameInvalid = new java.util.concurrent.atomic.AtomicBoolean();
when(invalidated.isDead()).thenAnswer(call -> becameInvalid.get());
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
var execution = execution(caster, active, first);
var chain = new ChainLightning(new ChainLightning.Settings(java.time.Duration.ofSeconds(18), 16, 6, 3), sessions, parties);
when(execution.definition()).thenReturn(chain.definition());
var applied = new LinkedHashMap<UUID, Double>();
doAnswer(call -> {
double amount = call.getArgument(0); applied.put(first.getUniqueId(), amount);
chain.acceptedDamage(new EntityDamageEvent(first, EntityDamageEvent.DamageCause.LIGHTNING,
(DamageSource) call.getArgument(1), amount)); becameInvalid.set(true); return null;
}).when(first).damage(anyDouble(), any(DamageSource.class));
doAnswer(call -> {
double amount = call.getArgument(0); applied.put(third.getUniqueId(), amount);
chain.acceptedDamage(new EntityDamageEvent(third, EntityDamageEvent.DamageCause.LIGHTNING,
(DamageSource) call.getArgument(1), amount)); return null;
}).when(third).damage(anyDouble(), any(DamageSource.class));
chain.activate(execution);
verify(invalidated, never()).damage(anyDouble(), any(DamageSource.class));
assertEquals(List.of(first.getUniqueId(), third.getUniqueId()), List.copyOf(applied.keySet()));
assertTrue(applied.values().stream().allMatch(value -> Math.abs(value - 20d / 3) < 1e-10));
verify(execution).resolve(eq("hit"), argThat(evidence -> evidence.get("selected_chain_size").equals(3)
&& evidence.get("attempted_hops").equals(2) && evidence.get("accepted_hops").equals(2)));
}
@Test void nonFiniteNativeShareIsRefusedBeforeAnyTargetDamage() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
var target = mock(LivingEntity.class); locate(target, world, UUID.randomUUID(), 10); when(caster.hasLineOfSight(target)).thenReturn(true);
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
var execution = execution(caster, active, target); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(chain.definition());
var ordinary = execution.character(); var stats = new EnumMap<>(ordinary.stats()); stats.put(Stat.INT, BigInteger.TEN.pow(500));
when(execution.character()).thenReturn(new CharacterRecord(ordinary.id(), ordinary.name(), ordinary.revision(), stats,
ordinary.level(), ordinary.experience(), ordinary.unspentPoints(), ordinary.health(), ordinary.learned(), ordinary.loadout(),
ordinary.cooldowns(), ordinary.restrictedKills(), false, null, null));
chain.activate(execution);
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
verify(execution).resolve(eq("failed"), argThat(evidence -> evidence.get("terminal_reason").equals("native_damage_not_finite")
&& evidence.get("attempted_hops").equals(0) && evidence.get("accepted_hops").equals(0)));
verify(execution, never()).acceptedDamage(any(), anyDouble(), anyDouble(), any(), anyMap());
} }
@Test void boundsVisibilityExclusionsAndEligibilityRaceStopInvalidStrikes() { @Test void boundsVisibilityExclusionsAndEligibilityRaceStopInvalidStrikes() {
@@ -244,47 +306,55 @@ class ChainLightningNativeTest {
var definition = chain.definition(); assertEquals("chain_lightning", definition.id()); assertEquals(org.bukkit.Material.LIGHTNING_ROD, definition.icon()); var definition = chain.definition(); assertEquals("chain_lightning", definition.id()); assertEquals(org.bukkit.Material.LIGHTNING_ROD, definition.icon());
assertEquals(java.time.Duration.ofSeconds(18), definition.cooldown()); assertEquals(BigInteger.ONE, definition.pointCost()); assertEquals(java.time.Duration.ofSeconds(18), definition.cooldown()); assertEquals(BigInteger.ONE, definition.pointCost());
assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.INT)); assertEquals(BigInteger.valueOf(12), definition.requirements().get(Stat.DEX)); assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.INT)); assertEquals(BigInteger.valueOf(12), definition.requirements().get(Stat.DEX));
assertEquals(BigInteger.valueOf(13), ChainLightning.initialDamage(BigInteger.valueOf(20))); assertEquals(BigInteger.valueOf(5), ChainLightning.intelligenceModifier(BigInteger.valueOf(20)));
assertEquals(BigInteger.valueOf(7), ChainLightning.initialDamage(BigInteger.valueOf(9))); assertEquals(BigInteger.valueOf(20), ChainLightning.totalDefinedDamage(BigInteger.valueOf(20)));
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(8)), ChainLightning.initialDamage(huge)); assertEquals(BigInteger.valueOf(40), ChainLightning.totalDefinedDamage(BigInteger.valueOf(30)));
assertEquals(BigInteger.valueOf(-1), ChainLightning.intelligenceModifier(BigInteger.valueOf(9)));
assertEquals(BigInteger.valueOf(-4), ChainLightning.totalDefinedDamage(BigInteger.valueOf(9)));
var huge = BigInteger.TEN.pow(500);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).multiply(BigInteger.valueOf(4)),
ChainLightning.totalDefinedDamage(huge));
assertEquals(new BigDecimal("4"), definition.parameters().get("int-modifier-damage-factor"));
assertEquals(new BigDecimal("20"), definition.parameters().get("max-targets"));
assertFalse(definition.parameters().containsKey("base-damage"));
assertFalse(definition.parameters().containsKey("jump-multiplier"));
} }
@Test void pluginLoadsEverySettingAndAdminPublishesOnlyAfterDurableSave() throws Exception { @Test void pluginLoadsEffectiveSettingsAndAdminPublishesOnlyAfterDurableSave() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 9\nchain-lightning-cooldown-seconds: 4\n" java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 999\nchain-lightning-cooldown-seconds: 4\n"
+ "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\nchain-lightning-jump-multiplier: 0.5\n"); + "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\n"
+ "chain-lightning-jump-multiplier: 0.5\nchain-lightning-target-int-step: 1\nchain-lightning-max-targets: 12\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) { try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry"); fixture.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var definition = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("9"), definition.parameters().get("base-damage")); var definition = registry.find("chain_lightning").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(4), definition.cooldown()); assertEquals(java.time.Duration.ofSeconds(4), definition.cooldown());
assertEquals(0, new BigDecimal("15").compareTo(definition.parameters().get("initial-range"))); assertEquals(0, new BigDecimal("15").compareTo(definition.parameters().get("initial-range")));
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("jump-range"))); assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("jump-range")));
assertEquals(new BigDecimal("2"), definition.parameters().get("additional-targets")); assertEquals(new BigDecimal("12"), definition.parameters().get("max-targets"));
assertEquals(new BigDecimal("0.5"), definition.parameters().get("jump-multiplier")); assertFalse(definition.parameters().containsKey("base-damage")); assertFalse(definition.parameters().containsKey("jump-multiplier"));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false; var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}).isEmpty()); assertTrue(fixture.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}).isEmpty());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7"}); fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true; assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("chain-lightning-additional-targets", "chain-lightning-base-damage", "chain-lightning-cooldown-seconds", assertEquals(List.of("chain-lightning-cooldown-seconds", "chain-lightning-initial-range-blocks",
"chain-lightning-initial-range-blocks", "chain-lightning-jump-multiplier", "chain-lightning-jump-range-blocks", "chain-lightning-jump-range-blocks", "chain-lightning-max-targets"),
"chain-lightning-max-targets", "chain-lightning-target-int-step"), fixture.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}));
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"})); assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fstickadmin",
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "chain-lightning-jump-range-blocks", ""})); new String[]{"config", "chain-lightning-jump-range-blocks", ""}));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks"}); fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks"});
assertTrue(sender.messages.getLast().contains("=5")); assertTrue(sender.messages.getLast().contains("=5"));
for (String[] update : List.of(new String[]{"chain-lightning-base-damage", "10"}, for (String[] update : List.of(new String[]{"chain-lightning-cooldown-seconds", "3"},
new String[]{"chain-lightning-cooldown-seconds", "3"}, new String[]{"chain-lightning-initial-range-blocks", "14"}, new String[]{"chain-lightning-initial-range-blocks", "14"},
new String[]{"chain-lightning-jump-range-blocks", "7"}, new String[]{"chain-lightning-additional-targets", "1"}, new String[]{"chain-lightning-jump-range-blocks", "7"},
new String[]{"chain-lightning-jump-multiplier", "0.25"})) { new String[]{"chain-lightning-max-targets", "3"})) {
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]}); fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", update[0], update[1]});
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run(); var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
} }
var configured = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("10"), configured.parameters().get("base-damage")); var configured = registry.find("chain_lightning").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown()); assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
assertEquals(0, new BigDecimal("14").compareTo(configured.parameters().get("initial-range"))); assertEquals(0, new BigDecimal("14").compareTo(configured.parameters().get("initial-range")));
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range"))); assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets")); assertEquals(new BigDecimal("3"), configured.parameters().get("max-targets"));
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE); java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) { var lock = channel.lock()) {
@@ -297,22 +367,22 @@ class ChainLightningNativeTest {
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings")) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings"))
.value("chain-lightning-jump-range-blocks")); .value("chain-lightning-jump-range-blocks"));
assertTrue(sender.messages.getLast().contains("save failed")); assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"}); fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-max-targets", "21"});
assertTrue(sender.messages.getLast().contains("invalid")); assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"}); fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
assertTrue(sender.messages.getLast().contains("Usage")); assertTrue(sender.messages.getLast().contains("Usage"));
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true); fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
var reloaded = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry"); var reloaded = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var saved = reloaded.find("chain_lightning").orElseThrow(); var saved = reloaded.find("chain_lightning").orElseThrow();
assertEquals(0, new BigDecimal("7").compareTo(saved.parameters().get("jump-range"))); assertEquals(0, new BigDecimal("7").compareTo(saved.parameters().get("jump-range")));
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier")); assertEquals(new BigDecimal("3"), saved.parameters().get("max-targets"));
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown()); assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
} }
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read") var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed")) || event.get("type").getAsString().endsWith("config.changed"))
.filter(event -> event.getAsJsonObject("data").has("key") .filter(event -> event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("chain-lightning-")).toList(); && event.getAsJsonObject("data").get("key").getAsString().startsWith("chain-lightning-")).toList();
assertEquals(7, config.size(), "One authorized read plus six acknowledged saved settings, no denied/invalid success"); assertEquals(5, config.size(), "One authorized read plus four acknowledged effective settings, no denied/invalid success");
} }
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) { private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
@@ -14,28 +14,30 @@ import org.junit.jupiter.api.io.TempDir;
class ChainLightningSettingsTest { class ChainLightningSettingsTest {
@TempDir Path data; @TempDir Path data;
@Test void defaultsAndParsingRetainExactDecay() { @Test void defaultsAndParsingExposeOnlyEffectivePolicyAndIgnoreObsoleteDamageKeys() {
var settings = ChainLightningSettings.parse(defaults()); var expected = java.util.List.of(ChainLightningSettings.COOLDOWN, ChainLightningSettings.INITIAL_RANGE,
assertEquals(java.math.BigInteger.valueOf(8), settings.baseDamage()); ChainLightningSettings.JUMP_RANGE, ChainLightningSettings.MAX_TARGETS);
assertEquals(expected, ChainLightningSettings.keys());
assertEquals("20", ChainLightningSettings.defaultValue(ChainLightningSettings.MAX_TARGETS));
var values = defaults();
values.put("chain-lightning-base-damage", java.math.BigInteger.TEN.pow(500).toString());
values.put("chain-lightning-additional-targets", "0");
values.put("chain-lightning-jump-multiplier", "0.01");
values.put("chain-lightning-target-int-step", "1");
var settings = ChainLightningSettings.parse(values);
assertEquals(java.time.Duration.ofSeconds(18), settings.cooldown()); assertEquals(java.time.Duration.ofSeconds(18), settings.cooldown());
assertEquals(16, settings.initialRange(), 0); assertEquals(6, settings.jumpRange(), 0); assertEquals(16, settings.initialRange(), 0); assertEquals(6, settings.jumpRange(), 0);
assertEquals(4, settings.additionalTargets()); assertEquals(20, settings.maxTargets());
assertEquals(new java.math.BigDecimal("0.75"), settings.jumpMultiplier());
} }
@Test void targetCoverageKeysAndPackagedDefaultsAreAvailable() { @Test void configuredChainCeilingSupportsOnlyOneThroughTwentyTargets() {
assertTrue(ChainLightningSettings.keys().containsAll(java.util.List.of("chain-lightning-target-int-step", "chain-lightning-max-targets")));
assertEquals("5", ChainLightningSettings.defaultValue("chain-lightning-target-int-step"));
assertEquals("12", ChainLightningSettings.defaultValue("chain-lightning-max-targets"));
var values = defaults(); var values = defaults();
for (String invalid : java.util.List.of("0", "-1", "1.5")) { for (String valid : java.util.List.of("1", "19", "20")) {
values.put("chain-lightning-target-int-step", invalid); values.put(ChainLightningSettings.MAX_TARGETS, valid);
assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values)); assertEquals(Integer.parseInt(valid), ChainLightningSettings.parse(values).maxTargets());
} }
values.put("chain-lightning-target-int-step", java.math.BigInteger.TEN.pow(500).toString()); for (String invalid : java.util.List.of("0", "21", "65", "1.5")) {
assertDoesNotThrow(() -> ChainLightningSettings.parse(values)); values.put(ChainLightningSettings.MAX_TARGETS, invalid);
for (String invalid : java.util.List.of("0", "65", "1.5")) {
values.put("chain-lightning-max-targets", invalid);
assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values)); assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values));
} }
} }
@@ -61,8 +63,7 @@ class ChainLightningSettingsTest {
try (var settings = new ChainLightningSettings(data, defaults())) { try (var settings = new ChainLightningSettings(data, defaults())) {
for (var entry : Map.of( for (var entry : Map.of(
ChainLightningSettings.COOLDOWN, "-1", ChainLightningSettings.INITIAL_RANGE, "0", ChainLightningSettings.COOLDOWN, "-1", ChainLightningSettings.INITIAL_RANGE, "0",
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.ADDITIONAL, "-1", ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.MAX_TARGETS, "21").entrySet()) {
ChainLightningSettings.BASE, "-1", ChainLightningSettings.MULTIPLIER, "0").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue())); assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
} }
assertThrows(IllegalArgumentException.class, () -> settings.set(ChainLightningSettings.COOLDOWN, "0.0000000001")); assertThrows(IllegalArgumentException.class, () -> settings.set(ChainLightningSettings.COOLDOWN, "0.0000000001"));
@@ -82,13 +83,13 @@ class ChainLightningSettingsTest {
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside); Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
try (var settings = new ChainLightningSettings(unsafe, defaults())) { try (var settings = new ChainLightningSettings(unsafe, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(ChainLightningSettings.MULTIPLIER, "0.5").toCompletableFuture().get(5, TimeUnit.SECONDS)); () -> settings.set(ChainLightningSettings.MAX_TARGETS, "10").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(outside)); assertEquals("safe\n", Files.readString(outside));
} }
} }
var closed = new ChainLightningSettings(data.resolve("closed"), defaults()); closed.close(); var closed = new ChainLightningSettings(data.resolve("closed"), defaults()); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class, assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set(ChainLightningSettings.BASE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS)); () -> closed.set(ChainLightningSettings.MAX_TARGETS, "19").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS); closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
} }