diff --git a/src/main/java/games/dmg/fantasystick/ability/FrostShard.java b/src/main/java/games/dmg/fantasystick/ability/FrostShard.java index 59665fd..4fd1285 100644 --- a/src/main/java/games/dmg/fantasystick/ability/FrostShard.java +++ b/src/main/java/games/dmg/fantasystick/ability/FrostShard.java @@ -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) { diff --git a/src/nativeTest/java/games/dmg/fantasystick/FrostShardNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FrostShardNativeTest.java index a91698a..61fc039 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FrostShardNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FrostShardNativeTest.java @@ -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 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(); 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); } } diff --git a/src/nativeTest/java/games/dmg/fantasystick/FrostShardRangeNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FrostShardRangeNativeTest.java index cebeb3c..03e25c6 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FrostShardRangeNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FrostShardRangeNativeTest.java @@ -47,25 +47,32 @@ class FrostShardRangeNativeTest { static java.util.stream.Stream 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()); diff --git a/src/nativeTest/java/games/dmg/fantasystick/TrainingDummyEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/TrainingDummyEventsNativeTest.java index 51985d2..30b00ed 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/TrainingDummyEventsNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/TrainingDummyEventsNativeTest.java @@ -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);