Compare commits

...
3 Commits
Author SHA1 Message Date
dmg b12d4cb5ca docs: point story tracking to Kaneo
Release / release (push) Successful in 3m4s
2026-10-28 20:36:09 -04:00
dmg df6474fd55 feat(chain-lightning): split damage across twenty targets (#20)
Release / release (push) Successful in 1m23s
Closes https://project.dmg.games/work_packages/478
2026-10-17 15:44:45 +00:00
dmg edada37669 feat(chain-lightning): split damage across twenty targets
CI / build (pull_request) Successful in 1m25s
CI / build (push) Successful in 2m0s
2026-10-17 11:21:11 -04:00
10 changed files with 322 additions and 197 deletions

No files matched your search

+5 -3
View File
@@ -1,11 +1,13 @@
# purpur-fantasy-stick agent entrypoint
The canonical stories, engineering guidance, and process documents live in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Canonical engineering guidance and technical/process documents are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-fantasy-stick/index.md`, that project's `engineering.md`, and relevant `../somc-okf/user-stories/purpur-fantasy-stick/` stories. Follow the parent workspace `AGENTS.md` when present. Do not invent defaults for explicitly unresolved gameplay or recovery decisions.
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-fantasy-stick] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-fantasy-stick/index.md`, that project's `engineering.md`, and relevant Kaneo tickets with subject prefix `[purpur-fantasy-stick]`. Follow the parent workspace `AGENTS.md` when present. Do not invent defaults for explicitly unresolved gameplay or recovery decisions.
For standalone checkouts, obtain access to the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-fantasy-stick/index.md) and [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Do not create a parallel knowledge bundle. Source builds do not require private wiki access.
Follow the [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, meaningful failing tests, minimal passing implementation, immediate criterion evidence, full verification, and source/wiki publication. GitOps preparation is separately approved and committed locally without pushing. Only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed production push.
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. GitOps preparation is separately approved and committed locally without pushing. Only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed production push.
Use Java 25 and the declared Purpur API. Run `./gradlew clean check jar`. Preserve runtime identity, persistent namespaces, and unrelated player/plugin state. Never expose release credentials in arguments, output, or Git.
@@ -255,7 +255,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return;
}
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);
boolean synchronizedProgress = unchangedDeadline
? 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 java.math.BigDecimal;
import java.math.BigInteger;
import java.math.MathContext;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
@@ -30,33 +31,36 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
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 record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
int additionalTargets, BigDecimal jumpMultiplier, BigInteger targetIntStep, int maxTargets) {
private static final BigInteger DAMAGE_FACTOR = BigInteger.valueOf(4);
private static final MathContext SHARE_PRECISION = MathContext.DECIMAL128;
public record Settings(Duration cooldown, double initialRange, double jumpRange, int maxTargets) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier); Objects.requireNonNull(targetIntStep);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
Objects.requireNonNull(cooldown);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|| additionalTargets < 0 || additionalTargets > 63 || targetIntStep.signum() <= 0 || maxTargets < 1 || maxTargets > 64
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
|| maxTargets < 1 || maxTargets > 20) {
throw new IllegalArgumentException("Invalid Chain Lightning settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 4, new BigDecimal("0.75"), BigInteger.valueOf(5), 12);
}
public static Settings defaults() { return new Settings(Duration.ofSeconds(18), 16, 6, 20); }
}
private record SelectedTarget(LivingEntity target, LivingEntity previous) { }
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; final double requested;
final BigDecimal defined; final UUID chainId; final int ordinal;
final UUID previousId; final Map<String, Object> previousLocation;
final BigDecimal totalDefined; final BigDecimal definedShare; final UUID chainId; final int ordinal;
final int selectedChainSize; final UUID previousId; final Map<String, Object> previousLocation;
boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
UUID chainId, int ordinal, LivingEntity previous) {
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal totalDefined,
BigDecimal definedShare, UUID chainId, int ordinal, int selectedChainSize, LivingEntity previous) {
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();
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
}
@@ -82,36 +86,30 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, with "
+ Math.min(current.maxTargets(), 1 + current.additionalTargets()) + " base total targets at INT 20.",
"Gain one target per " + current.targetIntStep() + " purchased INT above 20, capped at " + current.maxTargets() + " total targets.",
"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.",
List.of("Build one damage pool equal to four times your purchased INT modifier.",
"Discover up to " + current.maxTargets() + " targets, then divide that pool equally across the complete chain.",
"Start within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks; each nearest visible jump is within "
+ decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
"Use while aiming at the first visible hostile; cancelled or invalid shares are not redistributed.",
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(),
Map.of("initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
"jump-multiplier", current.jumpMultiplier(),
"target-int-step", new BigDecimal(current.targetIntStep()), "max-targets", BigDecimal.valueOf(current.maxTargets())), AbilityDefinition.TargetPolicy.SELECTION, this);
"max-targets", BigDecimal.valueOf(current.maxTargets()),
"int-modifier-damage-factor", new BigDecimal(DAMAGE_FACTOR)), AbilityDefinition.TargetPolicy.SELECTION, this);
}
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); }
static BigInteger initialDamage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning damage input");
public static BigInteger intelligenceModifier(BigInteger intelligence) {
Objects.requireNonNull(intelligence);
if (intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning INT");
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);
var modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return modifier;
}
public static int targetBudget(BigInteger intelligence, Settings captured) {
Objects.requireNonNull(intelligence); Objects.requireNonNull(captured);
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();
public static BigInteger totalDefinedDamage(BigInteger intelligence) {
return intelligenceModifier(intelligence).multiply(DAMAGE_FACTOR);
}
@Override public boolean accepts(ControlInput input) {
@@ -121,39 +119,76 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
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());
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 chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
int maximum = targetBudget(execution.character().stats().get(Stat.INT), captured);
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
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 selected = discover(execution, caster, initial, captured);
if (selected.isEmpty()) {
execution.resolve(execution.stillCurrent() ? "miss" : "interrupted",
Map.of("reason", execution.stillCurrent() ? "initial_target_became_ineligible" : "source_interrupted",
"selected_chain_size", 0, "attempted_hops", 0, "accepted_hops", 0));
return;
}
var outcome = new LinkedHashMap<String, Object>();
outcome.put("chain_id", chainId.toString());
var totalDefined = new BigDecimal(totalDefinedDamage(purchasedInt));
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_location", locationData(initial.getLocation()));
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
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);
}
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
double amount = finite(definiteNonnegative(defined));
private List<SelectedTarget> discover(AbilityExecution execution, Player caster, LivingEntity initial, Settings captured) {
var selected = new ArrayList<SelectedTarget>(); var visited = new HashSet<UUID>();
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 source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.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); }
finally { if (damaging == attempt) damaging = null; }
return attempt;
@@ -190,6 +225,8 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
private boolean eligible(Player caster, LivingEntity target) {
if (target == caster) return false;
try { if (target.isDead()) return false; }
catch (RuntimeException unavailable) { return false; }
if (!(target instanceof Player player)) return true;
if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false;
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>();
evidence.put("damage_kind", "owned_chain_strike");
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("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) {
evidence.put("previous_target_id", attempt.previousId.toString());
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);
} 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) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("initial-range").doubleValue(),
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"),
p.get("target-int-step").toBigIntegerExact(), p.get("max-targets").intValueExact());
return new Settings(definition.cooldown(), p.get("initial-range").doubleValue(),
p.get("jump-range").doubleValue(), p.get("max-targets").intValueExact());
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() { closed = true; damaging = null; }
@@ -94,6 +94,11 @@ public final class AbilityCastEventBridge {
"native_velocity_status"))
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")) {
data.put("draw_ticks", 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. */
public final class ChainLightningSettings implements AutoCloseable {
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 INITIAL_RANGE = "chain-lightning-initial-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";
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(
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; }
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) {
try { return new ChainLightning.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
seconds(values.get(COOLDOWN)), 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(INT_STEP)).toBigIntegerExact(), new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
try { return new ChainLightning.Settings(seconds(values.get(COOLDOWN)),
finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
catch (ArithmeticException | NumberFormatException | NullPointerException 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-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]
chain-lightning-base-damage: 8
chain-lightning-cooldown-seconds: 18
chain-lightning-initial-range-blocks: 16
chain-lightning-jump-range-blocks: 6
chain-lightning-additional-targets: 4
chain-lightning-jump-multiplier: 0.75
chain-lightning-target-int-step: 5
chain-lightning-max-targets: 12
chain-lightning-max-targets: 20
# Sanctuary defaults from the reviewed remaining-development packet.
# 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[]{"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[]{"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[]{"meteor-base-damage", "15"});
@@ -43,17 +43,13 @@ class ChainLightningCoverageNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
static Stream<Arguments> coverage() {
return Stream.of(
Arguments.of("20", "", 5, false), Arguments.of("24", "", 5, false),
Arguments.of("25", "", 6, false), Arguments.of("29", "", 6, false),
Arguments.of("30", "", 7, false), Arguments.of("34", "", 7, false),
Arguments.of("35", "", 8, false), Arguments.of("39", "", 8, false),
Arguments.of("54", "", 11, false), Arguments.of("55", "", 12, true),
Arguments.of(BigInteger.TEN.pow(500).toString(), "", 12, false),
Arguments.of("30", "chain-lightning-additional-targets: 3\n", 6, 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));
Arguments.of("20", "chain-lightning-max-targets: 1\n", 1, false),
Arguments.of("20", "chain-lightning-max-targets: 3\n", 3, false),
Arguments.of("20", "", 20, true),
Arguments.of("30", "chain-lightning-max-targets: 5\n", 5, false),
Arguments.of("30", "chain-lightning-max-targets: 2\n", 2, false),
Arguments.of("20", "chain-lightning-base-damage: 999\nchain-lightning-additional-targets: 0\n"
+ "chain-lightning-jump-multiplier: 0.01\nchain-lightning-target-int-step: 1\n", 20, false));
}
@ParameterizedTest @MethodSource("coverage")
void purchasedIntAndCapturedSettingsProduceActualUniqueHopsAndTruthfulDamage(
@@ -61,7 +57,7 @@ class ChainLightningCoverageNativeTest {
exercise(purchasedInt, retained, expected, cancelThird, false);
}
@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 {
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();
PartyBookNativeTest.click(f, caster, learn(caster)); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
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);
when(target.getUniqueId()).thenReturn(new UUID(0, index + 1)); when(target.getName()).thenReturn("Coverage " + index);
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.HUSK);
@@ -132,11 +128,14 @@ class ChainLightningCoverageNativeTest {
assertEquals(expected - (cancelThird ? 1 : 0), applied.size());
verify(targets.get(expected), never()).damage(anyDouble(), any(DamageSource.class));
var parameters = capturedDefinition.parameters();
int base = Math.min(parameters.get("max-targets").intValueExact(), 1 + parameters.get("additional-targets").intValueExact());
// BookPagination wraps by glyph width, including within words; preserve all actual text/numbers.
assertTrue(book.contains(base + "basetotaltargetsatINT20"), "book must explain the actual captured base");
assertTrue(book.contains("per" + parameters.get("target-int-step").toPlainString() + "purchasedINTabove20"));
assertTrue(book.contains("cappedat" + parameters.get("max-targets").toPlainString() + "totaltargets"));
assertEquals(expected, parameters.get("max-targets").intValueExact());
assertEquals(new BigDecimal("4"), parameters.get("int-modifier-damage-factor"));
assertFalse(parameters.keySet().stream().anyMatch(key -> List.of("base-damage", "additional-targets",
"jump-multiplier", "target-int-step").contains(key)));
// 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();
assertEquals(BigInteger.valueOf(39), saved.unspentPoints()); assertTrue(saved.learned().contains("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); }
});
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) {
var facts = row.getAsJsonObject("data"); int ordinal = facts.get("hop_ordinal").getAsInt();
var defined = new BigDecimal(ChainLightning.initialDamage(new BigInteger(purchasedInt)))
.multiply(new BigDecimal("0.75").pow(ordinal - 1));
assertEquals(defined.stripTrailingZeros().toPlainString(), facts.get("defined_amount").getAsString());
assertEquals(total.stripTrailingZeros().toPlainString(), facts.get("total_defined_pool").getAsString());
assertEquals(share.stripTrailingZeros().toPlainString(), facts.get("equal_defined_share").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);
assertTrue(facts.has("target_location")); if (ordinal > 1) assertTrue(facts.has("previous_target_id"));
if (cancelThird) assertNotEquals(3, ordinal);
}
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");
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(1, rows("ability.cast").size(), "duplicate pending input never starts another business cast");
if (changeDuringSave) {
@@ -175,47 +185,55 @@ class ChainLightningCoverageNativeTest {
}
}
}
@Test void newConfigurationKeysUseRegisteredPermissionCheckedDurableReadUpdateCompletionAndReload() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-additional-targets: 3\nunrelated: retained\n");
@Test void effectiveConfigurationUsesRegisteredDurableSurfaceAndPreservesIgnoredLegacyKeys() throws Exception {
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)) {
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());
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;
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");
assertEquals("3", store.value(ChainLightningSettings.ADDITIONAL), "retained explicit base is never silently replaced");
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", entry.getKey(), entry.getValue()});
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
assertEquals(entry.getValue(), store.value(entry.getKey()));
}
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", new String[]{"config", "chain-lightning-max-targets", "2", "extra"}).isEmpty());
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step"});
assertTrue(sender.messages.getLast().contains("=7"));
assertEquals("20", store.value(ChainLightningSettings.MAX_TARGETS));
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2"});
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
assertEquals("2", store.value(ChainLightningSettings.MAX_TARGETS));
assertEquals(List.of("2", "20"), f.command.tabComplete(sender.port, "fstickadmin",
new String[]{"config", ChainLightningSettings.MAX_TARGETS, ""}));
assertTrue(f.command.tabComplete(sender.port, "fstickadmin",
new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2", "extra"}).isEmpty());
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS});
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);
var lock = channel.lock()) {
assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", "1"});
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin",
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"));
for (String[] invalid : List.of(new String[]{"chain-lightning-target-int-step", "0"}, new String[]{"chain-lightning-target-int-step", "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", invalid[0], invalid[1]}); assertTrue(sender.messages.getLast().contains("invalid"));
assertEquals("2", store.value(ChainLightningSettings.MAX_TARGETS)); assertTrue(sender.messages.getLast().contains("save failed"));
for (String invalid : List.of("0", "21", "1.5")) {
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, 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");
assertEquals(new BigDecimal("7"), registry.find("chain_lightning").orElseThrow().parameters().get("target-int-step"));
assertEquals(new BigDecimal("2"), registry.find("chain_lightning").orElseThrow().parameters().get("max-targets"));
var parameters = registry.find("chain_lightning").orElseThrow().parameters();
assertEquals(new BigDecimal("2"), parameters.get("max-targets"));
assertFalse(parameters.containsKey("base-damage")); assertFalse(parameters.containsKey("target-int-step"));
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);
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("3", restarted.value(ChainLightningSettings.ADDITIONAL));
assertEquals("2", restarted.value(ChainLightningSettings.MAX_TARGETS));
}
}
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 {
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
&& call[2].equals(18)).count(), "An enabled nearby viewer retains every chain burst");
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(1, caster.messages.stream().filter("Chain Lightning did 13 damage to Zombie."::equals).count());
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Skeleton."::equals).count());
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 10 damage to Zombie."::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"))
.find(caster.core.record.id()).get().orElseThrow();
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");
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(13, hits.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
assertEquals(9.75, hits.getLast().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("actual_amount").getAsDouble() == 10));
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")));
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");
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(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));
use(fixture, caster); drain(fixture, 2);
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 9.75 damage to Second Target."::equals).count());
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 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 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));
@@ -198,10 +201,69 @@ class ChainLightningNativeTest {
(DamageSource) call.getArgument(1), amount)); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class)); }
chain.activate(execution);
assertEquals(targets.subList(0, 5).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);
verify(targets.get(5), never()).damage(anyDouble(), any(DamageSource.class));
verify(execution, times(4)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
assertEquals(targets.stream().map(LivingEntity::getUniqueId).toList(), List.copyOf(attempted.keySet()));
assertArrayEquals(new double[]{20d / 6, 20d / 6, 20d / 6, 20d / 6, 20d / 6, 20d / 6},
attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
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() {
@@ -244,47 +306,55 @@ class ChainLightningNativeTest {
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(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(7), ChainLightning.initialDamage(BigInteger.valueOf(9)));
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(8)), ChainLightning.initialDamage(huge));
assertEquals(BigInteger.valueOf(5), ChainLightning.intelligenceModifier(BigInteger.valueOf(20)));
assertEquals(BigInteger.valueOf(20), ChainLightning.totalDefinedDamage(BigInteger.valueOf(20)));
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 {
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 9\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");
@Test void pluginLoadsEffectiveSettingsAndAdminPublishesOnlyAfterDurableSave() throws Exception {
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\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)) {
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(0, new BigDecimal("15").compareTo(definition.parameters().get("initial-range")));
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("jump-range")));
assertEquals(new BigDecimal("2"), definition.parameters().get("additional-targets"));
assertEquals(new BigDecimal("0.5"), definition.parameters().get("jump-multiplier"));
assertEquals(new BigDecimal("12"), definition.parameters().get("max-targets"));
assertFalse(definition.parameters().containsKey("base-damage")); assertFalse(definition.parameters().containsKey("jump-multiplier"));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}).isEmpty());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
assertTrue(fixture.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}).isEmpty());
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;
assertEquals(List.of("chain-lightning-additional-targets", "chain-lightning-base-damage", "chain-lightning-cooldown-seconds",
"chain-lightning-initial-range-blocks", "chain-lightning-jump-multiplier", "chain-lightning-jump-range-blocks",
"chain-lightning-max-targets", "chain-lightning-target-int-step"),
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}));
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fantasystick",
assertEquals(List.of("chain-lightning-cooldown-seconds", "chain-lightning-initial-range-blocks",
"chain-lightning-jump-range-blocks", "chain-lightning-max-targets"),
fixture.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}));
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fstickadmin",
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"));
for (String[] update : List.of(new String[]{"chain-lightning-base-damage", "10"},
new String[]{"chain-lightning-cooldown-seconds", "3"}, 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-multiplier", "0.25"})) {
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
for (String[] update : List.of(new String[]{"chain-lightning-cooldown-seconds", "3"},
new String[]{"chain-lightning-initial-range-blocks", "14"},
new String[]{"chain-lightning-jump-range-blocks", "7"},
new String[]{"chain-lightning-max-targets", "3"})) {
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 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(0, new BigDecimal("14").compareTo(configured.parameters().get("initial-range")));
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
assertEquals(new BigDecimal("3"), configured.parameters().get("max-targets"));
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()) {
@@ -297,22 +367,22 @@ class ChainLightningNativeTest {
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings"))
.value("chain-lightning-jump-range-blocks"));
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"));
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"));
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
var reloaded = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var saved = reloaded.find("chain_lightning").orElseThrow();
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());
}
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))
.filter(event -> event.getAsJsonObject("data").has("key")
&& 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) {
@@ -14,28 +14,30 @@ import org.junit.jupiter.api.io.TempDir;
class ChainLightningSettingsTest {
@TempDir Path data;
@Test void defaultsAndParsingRetainExactDecay() {
var settings = ChainLightningSettings.parse(defaults());
assertEquals(java.math.BigInteger.valueOf(8), settings.baseDamage());
@Test void defaultsAndParsingExposeOnlyEffectivePolicyAndIgnoreObsoleteDamageKeys() {
var expected = java.util.List.of(ChainLightningSettings.COOLDOWN, ChainLightningSettings.INITIAL_RANGE,
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(16, settings.initialRange(), 0); assertEquals(6, settings.jumpRange(), 0);
assertEquals(4, settings.additionalTargets());
assertEquals(new java.math.BigDecimal("0.75"), settings.jumpMultiplier());
assertEquals(20, settings.maxTargets());
}
@Test void targetCoverageKeysAndPackagedDefaultsAreAvailable() {
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"));
@Test void configuredChainCeilingSupportsOnlyOneThroughTwentyTargets() {
var values = defaults();
for (String invalid : java.util.List.of("0", "-1", "1.5")) {
values.put("chain-lightning-target-int-step", invalid);
assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values));
for (String valid : java.util.List.of("1", "19", "20")) {
values.put(ChainLightningSettings.MAX_TARGETS, valid);
assertEquals(Integer.parseInt(valid), ChainLightningSettings.parse(values).maxTargets());
}
values.put("chain-lightning-target-int-step", java.math.BigInteger.TEN.pow(500).toString());
assertDoesNotThrow(() -> ChainLightningSettings.parse(values));
for (String invalid : java.util.List.of("0", "65", "1.5")) {
values.put("chain-lightning-max-targets", invalid);
for (String invalid : java.util.List.of("0", "21", "65", "1.5")) {
values.put(ChainLightningSettings.MAX_TARGETS, invalid);
assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values));
}
}
@@ -61,8 +63,7 @@ class ChainLightningSettingsTest {
try (var settings = new ChainLightningSettings(data, defaults())) {
for (var entry : Map.of(
ChainLightningSettings.COOLDOWN, "-1", ChainLightningSettings.INITIAL_RANGE, "0",
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.ADDITIONAL, "-1",
ChainLightningSettings.BASE, "-1", ChainLightningSettings.MULTIPLIER, "0").entrySet()) {
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.MAX_TARGETS, "21").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(ChainLightningSettings.COOLDOWN, "0.0000000001"));
@@ -82,13 +83,13 @@ class ChainLightningSettingsTest {
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
try (var settings = new ChainLightningSettings(unsafe, defaults())) {
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));
}
}
var closed = new ChainLightningSettings(data.resolve("closed"), defaults()); closed.close();
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);
}