Compare commits

...
1 Commits
Author SHA1 Message Date
dmg bc1a0b1f29 feat(passives): add basic-stick Knockback
Release / release (push) Successful in 8m54s
2026-09-21 18:34:13 -04:00
8 changed files with 244 additions and 9 deletions

No files matched your search

@@ -465,6 +465,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
passiveRegistry.register(healthSteal.definition());
passiveRegistry.register(speedI.definition());
passiveRegistry.register(lowLightVision.definition());
passiveRegistry.register(new games.dmg.fantasystick.passive.StickKnockback().definition());
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
+ "; passive " + problem.passive(), problem.cause()));
@@ -35,9 +35,10 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
final ControlInput input;
final LivingEntity target;
final Instant attackedAt;
final boolean knockback;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
this.input = input; this.target = target; this.attackedAt = attackedAt;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
}
}
@@ -72,7 +73,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
if (ready != null && now.isBefore(ready)) return;
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var attempt = new Pending(input, target, now);
var attempt = new Pending(input, target, now,
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
pending = attempt;
try {
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
@@ -82,7 +84,13 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
});
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
if (attempt.accepted) {
readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
if (attempt.knockback && !trainingDamage.target(target) && input.stillCurrent()
&& sessions.current(input.session())) {
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
}
}
}
}
@@ -96,7 +104,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
@@ -93,6 +93,9 @@ public final class SessionControls {
var meta = item.getItemMeta();
meta.displayName(Component.text(icon.name()));
meta.setMaxStackSize(1);
if (slot == 1 && character.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID)) {
meta.addEnchant(org.bukkit.enchantments.Enchantment.KNOCKBACK, 1, true);
}
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
@@ -0,0 +1,28 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Fixed reviewed metadata and native level-I behavior for the basic-stick Knockback passive. */
public final class StickKnockback {
public static final String ID = "stick_knockback";
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Knockback", Map.of(Stat.STR, BigInteger.valueOf(12)), Set.of(),
BigInteger.ONE, List.of("Your bound basic attack stick carries native Knockback I.",
"Only positive accepted basic-stick damage can knock the target back."));
}
public static void apply(org.bukkit.entity.Player source, org.bukkit.entity.LivingEntity target) {
double radians = Math.toRadians(source.getLocation().getYaw());
double directionX = Math.sin(radians);
double directionZ = -Math.cos(radians);
if (!Double.isFinite(directionX) || !Double.isFinite(directionZ)) return;
boolean sprinting = source.isSprinting();
target.knockback(sprinting ? 2 : 1, directionX, directionZ);
if (sprinting) source.setSprinting(false);
}
}
@@ -287,8 +287,8 @@ public final class EquipmentSessions implements AutoCloseable {
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
if(gear!=null&&!previous.gear().equals(saved.gear()))gear.install(attempt.player.getInventory(),saved,attempt.owner,attempt.session);
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
&& !refreshControls(attempt, session)) return false;
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns())
|| !previous.passives().equals(saved.passives())) && !refreshControls(attempt, session)) return false;
return true;
}
@@ -317,7 +317,7 @@ public final class EquipmentSessions implements AutoCloseable {
private boolean refreshControls(Attempt attempt, Active session) {
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
boolean changed = false;
for (int slot = 2; slot < 9; slot++) {
for (int slot = 1; slot < 9; slot++) {
if (active.get(attempt.owner) != attempt || !current(session)) return false;
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
attempt.player.getInventory().setItem(slot, refreshed[slot]);
@@ -148,7 +148,7 @@ class BasicAttackNativeTest {
old.revision());
}
private static final class MutableClock extends Clock {
static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
void advance(java.time.Duration duration) { now = now.plus(duration); }
@@ -1339,6 +1339,7 @@ class FantasyStickLifecycleNativeTest {
healthAttributes.getInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
boolean healthScaled;
boolean sprinting;
double healthScale = 20;
double absorption;
Runnable scaleMutation = () -> {};
@@ -1402,6 +1403,8 @@ class FantasyStickLifecycleNativeTest {
yield null;
}
case "isSneaking", "isInvisible" -> false;
case "isSprinting" -> sprinting;
case "setSprinting" -> { sprinting = (Boolean) args[0]; yield null; }
case "getScoreboard" -> craftBoard;
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
@@ -0,0 +1,192 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StickKnockbackNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookLearningRefreshesAuthenticStickAcceptedAttackTransfersAndUnlearns() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.passive.PassiveRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
var definition = registry.find("stick_knockback").orElseThrow();
assertEquals("Knockback", definition.name()); assertEquals(BigInteger.valueOf(12),
definition.requirements().get(games.dmg.fantasystick.progression.Stat.STR));
assertEquals(BigInteger.ONE, definition.pointCost()); assertTrue(definition.prerequisites().isEmpty());
var ineligible = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, ineligible, 11, List.of("", "", ""));
f.players.put(ineligible.core.id, ineligible.player); f.activate(ineligible); PartyBookNativeTest.open(f, ineligible);
var refusedPages = pages(ineligible); var refusedDetail = changePageTo(refusedPages, "Knockback"); assertNotNull(refusedDetail);
String refused = refusedPages.get(refusedDetail.page() - 1).getString().replaceAll("\\s+", "");
assertTrue(refused.contains("STR12")); assertNull(findCustom(refusedPages.get(refusedDetail.page() - 1),
"fantasystick:passive_unlock"));
ineligible.core.inventory.setItemInOffHand(null); f.changed(ineligible); f.awaitCharacterAndReceipt(ineligible);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12, List.of("", "", ""));
f.players.put(actor.core.id, actor.player); f.activate(actor); PartyBookNativeTest.open(f, actor);
var pages = pages(actor); var detail = changePageTo(pages, "Knockback"); assertNotNull(detail);
assertTrue(pages.get(detail.page() - 1).getString().replaceAll("\\s+", "").contains("STR12[Learn]"));
PartyBookNativeTest.click(f, actor, custom(pages.get(detail.page() - 1), "fantasystick:passive_unlock"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var learned = storage(f).find(actor.core.record.id()).get().orElseThrow();
assertEquals(List.of("stick_knockback", "", ""), learned.passives());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
assertEquals(1, actor.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
actor.core.inventory.getInventory().setSelectedSlot(1);
var target = target();
doAnswer(call -> { accepted(f, actor, target, (Double) call.getArgument(0), false, false); return null; })
.when(target).damage(anyDouble(), same(actor.player));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
verify(target).knockback(eq(1.0), anyDouble(), anyDouble());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
actor.core.inventory.setItemInOffHand(null); f.changed(actor); f.awaitCharacterAndReceipt(actor);
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
holder.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(learned));
f.players.put(holder.core.id, holder.player); f.activate(holder);
assertEquals(1, holder.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
PartyBookNativeTest.open(f, holder);
PartyBookNativeTest.click(f, holder, custom(pages(holder), "fantasystick:passive_unlearn"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage(f).find(learned.id()).get().orElseThrow();
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertEquals(0, holder.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
holder.core.inventory.setItemInOffHand(null); f.changed(holder); f.awaitCharacterAndReceipt(holder);
assertTrue(holder.core.inventory.getItem(1) == null || holder.core.inventory.getItem(1).isEmpty());
}
}
@Test void cancelledZeroStaleEarlyAndTrainingHitsCannotKnockBackWhileNativeSprintStrengthIsRetained() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12,
List.of("stick_knockback", "", ""));
f.players.put(actor.core.id, actor.player); f.activate(actor);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(actor.core.id).orElseThrow();
var clock = new BasicAttackNativeTest.MutableClock(java.time.Instant.parse("2026-09-21T00:00:00Z"));
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
var target = target();
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
doAnswer(call -> {
var event = accepted(f, actor, target, (Double) call.getArgument(0), cancelled.get(), zero.get());
basic.acceptedDamage(event); return null;
}).when(target).damage(anyDouble(), same(actor.player));
var input = new games.dmg.fantasystick.session.ControlInput(actor.player, active, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> sessions.current(active));
basic.accept(input); verify(target, never()).knockback(anyDouble(), anyDouble(), anyDouble());
cancelled.set(false); zero.set(true); basic.accept(input);
verify(target, never()).knockback(anyDouble(), anyDouble(), anyDouble());
zero.set(false); actor.sprinting = true;
basic.accept(input); verify(target).knockback(eq(2.0), anyDouble(), anyDouble()); assertFalse(actor.sprinting);
basic.accept(input); verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
actor.core.inventory.setItemInOffHand(null); f.changed(actor);
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
var current = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, current, 12,
List.of("stick_knockback", "", "")); f.players.put(current.core.id, current.player); f.activate(current);
var currentSession = sessions.active(current.core.id).orElseThrow();
var dummy = target();
var training = new games.dmg.fantasystick.training.TrainingDamage() {
@Override public java.util.Optional<java.math.BigDecimal> apply(org.bukkit.entity.Player source,
LivingEntity ignored, Runnable nativeDamage) {
return java.util.Optional.of(java.math.BigDecimal.ONE);
}
@Override public boolean target(org.bukkit.entity.Entity entity) { return entity == dummy; }
};
var measured = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock, null, training);
measured.accept(new games.dmg.fantasystick.session.ControlInput(current.player, currentSession, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, dummy, 0, 1,
() -> sessions.current(currentSession)));
verify(dummy, never()).knockback(anyDouble(), anyDouble(), anyDouble());
}
}
private static LivingEntity target() {
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return target;
}
private static EntityDamageByEntityEvent accepted(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor, LivingEntity target, double amount, boolean cancelled, boolean zero) {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(actor.player); when(source.getDirectEntity()).thenReturn(actor.player);
var event = new EntityDamageByEntityEvent(actor.player, target, DamageCause.ENTITY_ATTACK, source, amount);
event.setCancelled(cancelled); if (zero) event.setDamage(0); f.dispatcher.accept(event); return event;
}
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength,
List<String> passives) 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.STR, BigInteger.valueOf(strength));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), BigInteger.valueOf(40), old.health(),
old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null,
old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()), old.revision());
}
private static games.dmg.fantasystick.persistence.CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static List<net.minecraft.network.chat.Component> pages(FantasyStickLifecycleNativeTest.Actor actor) {
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream().map(page -> page.raw()).toList();
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(
List<net.minecraft.network.chat.Component> pages, String heading) {
for (var page : pages) { var found = changePageTo(page, pages, heading); if (found != null) return found; }
return null;
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(net.minecraft.network.chat.Component component,
List<net.minecraft.network.chat.Component> pages, String heading) {
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page
&& page.page() > 0 && page.page() <= pages.size()
&& pages.get(page.page() - 1).getString().replaceAll("\\s+", "")
.startsWith(heading.replaceAll("\\s+", ""))) return page;
for (var child : component.getSiblings()) { var found = changePageTo(child, pages, heading); if (found != null) return found; }
return null;
}
private static net.minecraft.network.chat.ClickEvent.Custom custom(net.minecraft.network.chat.Component component, String id) {
var found = findCustom(component, id);
if (found == null) throw new AssertionError("Missing custom action " + id);
return found;
}
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
for (var page : pages) { var found = findCustom(page, id); if (found != null) return found; }
throw new AssertionError("Missing custom action " + id);
}
private static net.minecraft.network.chat.ClickEvent.Custom findCustom(
net.minecraft.network.chat.Component component, String id) {
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom action
&& action.id().toString().equals(id)) return action;
for (var child : component.getSiblings()) { var found = findCustom(child, id); if (found != null) return found; }
return null;
}
}