feat(frost-shard): quadruple base-plus-INT damage
CI / build (push) Successful in 55s
CI / build (pull_request) Successful in 2m6s

This commit is contained in:
dmg committed 2026-10-15 07:20:29 -04:00
1 parent 22c46f6780
commit 167573f953
4 files changed
+96 -35

No files matched your search

@@ -151,7 +151,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
var current = settings;
return new AbilityDefinition("frost_shard", "Frost Shard", Material.PRISMARINE_SHARD,
List.of("Launch a frost projectile with a " + BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString() + "-block travel limit.",
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
"Deals 4 * (" + current.baseDamage() + " + INT modifier) damage and Slowness "
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
"Use the equipped shard; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
@@ -187,7 +187,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
return base.add(modifier).multiply(BigInteger.valueOf(4));
}
@Override public void activate(AbilityExecution execution) {
@@ -73,6 +73,8 @@ class FrostShardNativeTest {
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
when(target.addPotionEffect(any())).thenReturn(true);
var vanilla = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
@@ -89,7 +91,7 @@ class FrostShardNativeTest {
var hit = new org.bukkit.event.entity.ProjectileHitEvent(projectile, target);
f.dispatcher.accept(hit);
assertFalse(hit.isCancelled(), "Eligible hostile Frost Shard hit was unexpectedly cancelled");
assertEquals(1, damages.size()); assertEquals(5.0, damages.getFirst().getDamage());
assertEquals(1, damages.size()); assertEquals(20.0, damages.getFirst().getDamage());
var effect = org.mockito.ArgumentCaptor.forClass(org.bukkit.potion.PotionEffect.class);
verify(target).addPotionEffect(effect.capture());
assertEquals(org.bukkit.potion.PotionEffectType.SLOWNESS, effect.getValue().getType());
@@ -121,8 +123,8 @@ class FrostShardNativeTest {
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(resolution.get("projectile_id").getAsString(), damage.get("projectile_id").getAsString());
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
assertEquals(5, damage.get("requested_amount").getAsDouble());
assertEquals(5, damage.get("actual_amount").getAsDouble());
assertEquals(20, damage.get("requested_amount").getAsDouble());
assertEquals(20, damage.get("actual_amount").getAsDouble());
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
assertTrue(damage.has("direct_entity_id"));
}
@@ -150,8 +152,23 @@ class FrostShardNativeTest {
frost.close();
}
@Test void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch() throws Exception {
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> pendingDamageCases() {
return java.util.stream.Stream.of(
org.junit.jupiter.params.provider.Arguments.of(BigInteger.ZERO, -4.0),
org.junit.jupiter.params.provider.Arguments.of(BigInteger.valueOf(12), 20.0),
org.junit.jupiter.params.provider.Arguments.of(BigInteger.valueOf(20), 36.0),
org.junit.jupiter.params.provider.Arguments.of(BigInteger.TEN.pow(500), Double.MAX_VALUE));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.MethodSource("pendingDamageCases")
void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch(BigInteger intelligence, double expectedDamage) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
var base = actor.core.record; var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, intelligence);
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
base.level(), base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.cooldowns(),
base.restrictedKills(), false, null, null);
var particles = mock(games.dmg.fantasystick.presentation.ParticleDelivery.class);
var frost = new games.dmg.fantasystick.ability.FrostShard(
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(), particles);
@@ -164,7 +181,7 @@ class FrostShardNativeTest {
when(execution.stillCurrent()).thenReturn(true);
when(execution.definition()).thenReturn(captured);
when(execution.input()).thenReturn(input);
when(execution.character()).thenReturn(actor.core.record);
when(execution.character()).thenReturn(record);
when(input.player()).thenReturn(actor.player);
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
@@ -181,8 +198,8 @@ class FrostShardNativeTest {
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
var amount = org.mockito.ArgumentCaptor.forClass(double.class);
verify(execution).damage(eq(target), amount.capture(), any(org.bukkit.damage.DamageSource.class));
assertEquals(games.dmg.fantasystick.ability.FrostShard.damage(actor.core.record.stats()
.get(games.dmg.fantasystick.progression.Stat.INT)).doubleValue(), amount.getValue());
assertEquals(expectedDamage, amount.getValue());
assertTrue(Double.isFinite(amount.getValue()), "uncapped INT must convert to finite native damage");
frost.close();
}
@@ -652,12 +669,18 @@ class FrostShardNativeTest {
.contains("Unavailable:requirements"));
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "frost_shard"));
}
assertEquals(BigInteger.valueOf(4), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
assertEquals(BigInteger.valueOf(16), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
assertEquals(BigInteger.valueOf(20), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)).multiply(BigInteger.valueOf(4)),
games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN.pow(500)));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"0,-4", "1,-4", "9,12", "10,16", "11,16", "12,20", "13,20", "20,36", "21,36"})
void fourfoldDamagePreservesMathematicalFloor(int intelligence, int expected) {
assertEquals(BigInteger.valueOf(expected), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(intelligence)));
}
@Test void invalidPackagedFrostSettingsFailBeforeInstallingGameplay() throws Exception {
for (String configuration : java.util.List.of("frost-shard-base-damage: -1\n",
"frost-shard-cooldown-seconds: 31536001\n", "frost-shard-range-blocks: 0\n",
@@ -787,7 +810,9 @@ class FrostShardNativeTest {
}
}
@Test void acknowledgedUpdatesAffectFutureCastsButNotAnActiveProjectileSnapshot() throws Exception {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void acknowledgedUpdatesAffectFutureCastsButNotPendingOrInFlightSnapshots(boolean pending) throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -795,23 +820,39 @@ class FrostShardNativeTest {
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var oldProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
var newProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
prepareLaunches(f, a, oldProjectile, newProjectile); cast(f, a);
prepareLaunches(f, a, oldProjectile, newProjectile);
Runnable pendingLaunch = null;
if (pending) {
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
pendingLaunch = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(pendingLaunch);
verify(a.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
} else cast(f, a);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var deadlines = repository.load(a.core.record.id()).orElseThrow().cooldowns();
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(deadlines, repository.load(a.core.record.id()).orElseThrow().cooldowns());
if (pendingLaunch != null) pendingLaunch.run();
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); when(target.getWorld()).thenReturn(a.world);
when(target.getName()).thenReturn("Snapshot hostile");
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0)); when(target.addPotionEffect(any())).thenReturn(true);
var damages = new java.util.ArrayList<Double>();
doAnswer(call -> {
damages.add(call.getArgument(0)); var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(damages.size() == 1 ? oldProjectile : newProjectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(oldProjectile, target));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(newProjectile, target));
assertEquals(java.util.List.of(5.0, 10.0), damages);
assertEquals(java.util.List.of(20.0, 40.0), damages);
}
}
@@ -47,25 +47,32 @@ class FrostShardRangeNativeTest {
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> distantCases() {
return java.util.stream.Stream.of(
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit"),
org.junit.jupiter.params.provider.Arguments.of(48.0, "", "hit"),
org.junit.jupiter.params.provider.Arguments.of(48.01, "", "miss"),
org.junit.jupiter.params.provider.Arguments.of(36.0, "retained", "expired"),
org.junit.jupiter.params.provider.Arguments.of(36.0, "snapshot", "expired"),
org.junit.jupiter.params.provider.Arguments.of(36.0, "cancelled", "cancelled"),
org.junit.jupiter.params.provider.Arguments.of(36.0, "obstructed", "miss"),
org.junit.jupiter.params.provider.Arguments.of(36.0, "empty", "expired"));
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 12, 4, 20),
org.junit.jupiter.params.provider.Arguments.of(48.0, "", "hit", 12, 4, 20),
org.junit.jupiter.params.provider.Arguments.of(48.01, "", "miss", 12, 4, 20),
org.junit.jupiter.params.provider.Arguments.of(36.0, "retained", "expired", 12, 4, 20),
org.junit.jupiter.params.provider.Arguments.of(36.0, "snapshot", "expired", 12, 4, 20),
org.junit.jupiter.params.provider.Arguments.of(36.0, "cancelled", "cancelled", 12, 4, 20),
org.junit.jupiter.params.provider.Arguments.of(36.0, "obstructed", "miss", 12, 4, 20),
org.junit.jupiter.params.provider.Arguments.of(36.0, "empty", "expired", 12, 4, 20),
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 20, 4, 36),
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 21, 4, 36),
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 12, 9, 40),
org.junit.jupiter.params.provider.Arguments.of(36.0, "mitigated", "hit", 20, 9, 56),
org.junit.jupiter.params.provider.Arguments.of(36.0, "zero", "miss", 12, 4, 20));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.MethodSource("distantCases")
void bookEquippedRealArrowFlightNativeDistantImpactSlowAndTruthfulJsonl(double distance, String mode, String outcome) throws Exception {
void bookEquippedRealArrowFlightNativeDistantImpactSlowAndTruthfulJsonl(double distance, String mode, String outcome,
int intelligence, int base, double requestedDamage) throws Exception {
boolean retained = mode.equals("retained") || mode.equals("snapshot");
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\n"
String retainedConfiguration = "adventure-daily-site-count: 0\nfrost-shard-base-damage: " + base + "\n"
+ (retained ? "frost-shard-range-blocks: 32\n" : "")
+ (mode.equals("snapshot") ? "frost-shard-cooldown-seconds: 0\n" : ""));
+ (mode.equals("snapshot") ? "frost-shard-cooldown-seconds: 0\n" : "");
java.nio.file.Files.writeString(data.resolve("config.yml"), retainedConfiguration);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); seed(actor);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); seed(actor, intelligence);
f.players.put(actor.core.id, actor.player); f.activate(actor);
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, actor);
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
@@ -121,6 +128,8 @@ class FrostShardRangeNativeTest {
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
damageEvents.add(event); if (mode.equals("cancelled")) event.setCancelled(true);
if (mode.equals("mitigated")) event.setDamage(event.getDamage() / 2);
if (mode.equals("zero")) event.setDamage(0);
f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
nativeArrow.delivery = result -> {
@@ -162,11 +171,14 @@ class FrostShardRangeNativeTest {
}
assertTrue(nativeArrow.isRemoved(), "impact or captured range expiry must physically discard owned Arrow");
boolean accepted = outcome.equals("hit");
assertEquals(accepted || mode.equals("cancelled") ? 1 : 0, damageEvents.size(), "only actual in-range native collision may attempt damage");
assertEquals(accepted || mode.equals("cancelled") || mode.equals("zero") ? 1 : 0, damageEvents.size(),
"only actual in-range native collision may attempt damage");
double finalDamage = mode.equals("mitigated") ? requestedDamage / 2 : requestedDamage;
if (!damageEvents.isEmpty()) verify(target, times(1)).damage(eq(requestedDamage), any(org.bukkit.damage.DamageSource.class));
if (accepted) {
assertNotNull(nativeArrow.hit); assertEquals(HitResult.Type.ENTITY, nativeArrow.hit.getType());
assertEquals(distance, nativeArrow.position().distanceTo(origin), 1e-9);
assertEquals(5, damageEvents.getFirst().getFinalDamage()); assertFalse(damageEvents.getFirst().isCancelled());
assertEquals(finalDamage, damageEvents.getFirst().getFinalDamage()); assertFalse(damageEvents.getFirst().isCancelled());
assertSame(actor.player, damageEvents.getFirst().getDamageSource().getCausingEntity());
assertSame(bukkit, damageEvents.getFirst().getDamageSource().getDirectEntity());
assertNotNull(slow.get()); assertEquals(40, slow.get().getDuration()); assertEquals(0, slow.get().getAmplifier());
@@ -187,12 +199,18 @@ class FrostShardRangeNativeTest {
var capture = rows("ability.cast").getFirst().getAsJsonObject("data");
assertEquals(retained ? "32" : "48", capture.getAsJsonObject("captured_settings").get("range").getAsString());
assertTrue(book.contains((retained ? "32" : "48") + "-blocktravellimit"), "real book must explain captured effective reach");
assertEquals(Integer.toString(intelligence), capture.get("captured_int").getAsString());
assertEquals(Integer.toString(base), capture.getAsJsonObject("captured_settings").get("base-damage").getAsString());
assertTrue(book.contains("4*(" + base + "+INTmodifier)"), "book must expose the effective full fourfold formula");
if (!mode.equals("snapshot")) assertEquals(retainedConfiguration, java.nio.file.Files.readString(data.resolve("config.yml")),
"loading and casting must not rewrite retained configured bases");
if (accepted) {
var damage = rows("combat.damage").getFirst().getAsJsonObject("data");
assertEquals(capture.get("execution_id"), damage.get("execution_id"));
assertEquals(bukkit.getUniqueId().toString(), damage.get("projectile_id").getAsString());
assertEquals(nativeArrow.getX(), damage.getAsJsonObject("impact_location").get("x").getAsDouble(), 1e-9);
assertEquals(5, damage.get("actual_amount").getAsDouble());
assertEquals(requestedDamage, damage.get("requested_amount").getAsDouble());
assertEquals(finalDamage, damage.get("actual_amount").getAsDouble());
}
if (mode.equals("snapshot")) {
var next = new CollisionArrow(actor.world.getHandle());
@@ -282,10 +300,10 @@ class FrostShardRangeNativeTest {
throw new AssertionError("bounded actual native flight cannot reach fixture collision face");
} finally { oracle.discard(); }
}
private void seed(FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
private void seed(FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(12));
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(intelligence));
stats.put(games.dmg.fantasystick.progression.Stat.WIS, java.math.BigInteger.TEN);
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE), stats,
old.level(), old.experience(), java.math.BigInteger.valueOf(40), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
@@ -227,6 +227,8 @@ class TrainingDummyEventsNativeTest {
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, ports.entity(),
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, (org.bukkit.damage.DamageSource) call.getArgument(1), (Double) call.getArgument(0));
assertSame(arrow, event.getDamageSource().getDirectEntity(), "The typed native source must retain the actual owned projectile");
assertEquals(abilityId.equals("frost_shard") ? 20.0 : 7.0, event.getDamage(),
"Frost Shard multiplies once before native modification; Fireball and Longbow remain unchanged");
event.setDamage(4.875); hits.add(event); f.dispatcher.accept(event); return null;
}).when(ports.entity()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
clearInvocations(ports.entity()); player.core.inventory.getInventory().setSelectedSlot(2);