Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636ff9f076 |
No files matched your search
@@ -44,6 +44,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
|
||||
private games.dmg.fantasystick.session.SessionVitals vitals;
|
||||
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
|
||||
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -121,7 +122,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
});
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
basicAttack = localBasicAttack;
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
|
||||
books.accept(input);
|
||||
localBasicAttack.accept(input);
|
||||
});
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings);
|
||||
@@ -136,6 +142,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
|
||||
@@ -173,6 +180,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingModes = gameModes; gameModes = null;
|
||||
var closingVitals = vitals; vitals = null;
|
||||
var closingVisuals = sessionVisuals; sessionVisuals = null;
|
||||
var closingBasicAttack = basicAttack; basicAttack = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
@@ -182,6 +190,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
cleanup("configuration", closingSettings::close);
|
||||
}
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
@@ -193,6 +202,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
|
||||
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
|
||||
private static final Duration CADENCE = Duration.ofSeconds(1);
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
private static final class Pending {
|
||||
final ControlInput input;
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions) {
|
||||
this(sessions, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions, Clock clock) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|
||||
|| input.gesture() != ControlInput.Gesture.ATTACK
|
||||
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) return;
|
||||
Instant now = clock.instant();
|
||||
Instant ready = readyAt.get(input.session().character());
|
||||
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);
|
||||
pending = attempt;
|
||||
try {
|
||||
target.damage(nativeDamage, input.player());
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
}
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
pending = null;
|
||||
readyAt.clear();
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Current transferable character snapshot for a still-authorized server-thread action. */
|
||||
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return current(session) && attempt != null && attempt.record != null
|
||||
? Optional.of(attempt.record) : Optional.empty();
|
||||
}
|
||||
|
||||
/** Apply a persisted progression revision to the still-current active session. */
|
||||
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
|
||||
var attempt = active.get(session.player());
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class BasicAttackNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void successfulHitsStartAnExactOneSecondCadenceButCancelledDamageDoesNot() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(a.core.id).orElseThrow();
|
||||
var clock = new MutableClock(Instant.parse("2026-09-19T00:00:00Z"));
|
||||
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
when(source.getDirectEntity()).thenReturn(a.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
event.setCancelled(cancelled.get());
|
||||
basic.acceptedDamage(event);
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), same(a.player));
|
||||
var input = new games.dmg.fantasystick.session.ControlInput(a.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); // Cancelled: no cadence spent.
|
||||
cancelled.set(false);
|
||||
basic.accept(input); // Accepted at t=0.
|
||||
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
|
||||
verify(target, times(2)).damage(1.0, a.player);
|
||||
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
|
||||
verify(target, times(3)).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
|
||||
verifyNoMoreInteractions(target);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyCancellationPreventsDamageAndDoesNotSpendThePackagedAttackCadence() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(a.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(a.player);
|
||||
when(source.getDirectEntity()).thenReturn(a.player);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
|
||||
events.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(b.player).damage(anyDouble(), same(a.player));
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
|
||||
assertTrue(parties.leave(a.core.id, party.id()));
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled(), "The icon stick must never continue into vanilla damage");
|
||||
verify(target).damage(1.0, a.player);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
assertTrue(a.core.inventory.getItem(1) == null || a.core.inventory.getItem(1).isEmpty(),
|
||||
"Exit restores the ordinary item that occupied the basic-stick slot");
|
||||
}
|
||||
}
|
||||
|
||||
private 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); }
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||
@Override public Instant instant() { return now; }
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertEquals(3, fixture.timers.size(), "Presentation, combat-provenance cleanup and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BasicAttackTest {
|
||||
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
|
||||
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
|
||||
var huge = BigInteger.TEN.pow(1000);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
|
||||
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
|
||||
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user