This commit is contained in:
1 parent
0486de9b30
commit
11d85a0a69
11 files changed
+811
-8
No files matched your search
@@ -187,6 +187,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.StickKnockbackEventBridge stickKnockbackEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> stickKnockbackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.HardMobXpEventBridge hardMobXpAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> hardMobXpAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.TrainingEventBridge trainingAuditEvents;
|
||||
@@ -875,7 +877,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.enableCastEventsFor("war_banner");
|
||||
abilityEngine.enableCastEventsFor("meteor");
|
||||
admin.attachAbilityConfigEvents(new games.dmg.fantasystick.events.AbilityConfigEventBridge(eventLog::emit, Clock.systemUTC()));
|
||||
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
|
||||
var knockbackAudit = new games.dmg.fantasystick.events.StickKnockbackEventBridge(eventLog::emit,
|
||||
getDataFolder().toPath(), failure -> getLogger().log(Level.WARNING, "Knockback event intent failed", failure));
|
||||
stickKnockbackEvents = knockbackAudit;
|
||||
basicAttack.attachKnockbackEvents(knockbackAudit);
|
||||
getServer().getPluginManager().registerEvents(knockbackAudit, this);
|
||||
var passiveWriter = eventLog;
|
||||
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(input -> {
|
||||
if (games.dmg.fantasystick.passive.StickKnockback.ID.equals(input.data().get("passive_id"))
|
||||
&& input.type().startsWith("passive.")) knockbackAudit.acknowledgedPassive(input);
|
||||
else passiveWriter.emit(input);
|
||||
}, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
|
||||
healthStealEvents = new games.dmg.fantasystick.events.HealthStealEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Health Steal event intent failed", failure));
|
||||
@@ -1089,6 +1101,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
|
||||
if (replayPassivePotionEvents != null) replayPassivePotionEvents.replay();
|
||||
});
|
||||
var replayKnockbackEvents = stickKnockbackEvents;
|
||||
stickKnockbackAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous Knockback event drain failed", failure);
|
||||
if (replayKnockbackEvents != null) replayKnockbackEvents.replay();
|
||||
});
|
||||
var replayHardMobXpEvents = hardMobXpAuditEvents;
|
||||
hardMobXpAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous hard-mob XP event drain failed", failure);
|
||||
@@ -1207,6 +1224,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
|
||||
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
|
||||
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
|
||||
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
|
||||
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
|
||||
var closingTrainingEvents = trainingAuditEvents; trainingAuditEvents = null;
|
||||
var closingArmorEvents = armorAuditEvents; armorAuditEvents = null;
|
||||
@@ -1622,6 +1640,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingCombatRewardEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingCombatRewardEvents.drained());
|
||||
});
|
||||
if (closingKnockbackEvents != null) cleanup("Knockback event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
stickKnockbackAuditDrain = storageDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
|
||||
closingKnockbackEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingKnockbackEvents.drained());
|
||||
});
|
||||
if (closingHealthStealEvents != null) cleanup("Health Steal event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
@@ -1740,6 +1766,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingAdminRevival);
|
||||
unregister(closingCharacterRecovery);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingKnockbackEvents);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingOutOfCombatRecovery);
|
||||
unregister(closingBooks);
|
||||
|
||||
@@ -900,7 +900,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
|| games.dmg.fantasystick.passive.QuickerRegenerationII.ID.equals(passive)
|
||||
|| games.dmg.fantasystick.passive.HealthSteal.ID.equals(passive)
|
||||
|| games.dmg.fantasystick.passive.SpeedI.ID.equals(passive)
|
||||
|| games.dmg.fantasystick.passive.LowLightVision.ID.equals(passive);
|
||||
|| games.dmg.fantasystick.passive.LowLightVision.ID.equals(passive)
|
||||
|| games.dmg.fantasystick.passive.StickKnockback.ID.equals(passive);
|
||||
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|
||||
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
|
||||
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private games.dmg.fantasystick.events.BasicAttackEventBridge events;
|
||||
private games.dmg.fantasystick.events.StickKnockbackEventBridge knockbackEvents;
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
@@ -77,6 +78,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public void attachKnockbackEvents(games.dmg.fantasystick.events.StickKnockbackEventBridge bridge) {
|
||||
if (closed || knockbackEvents != null) throw new IllegalStateException("Knockback events already attached");
|
||||
knockbackEvents = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (input.kind() != ControlInput.Kind.BASIC_ATTACK || input.gesture() != ControlInput.Gesture.ATTACK) return;
|
||||
Instant now = clock.instant();
|
||||
@@ -114,7 +120,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
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);
|
||||
if (knockbackEvents == null) games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
|
||||
else knockbackEvents.apply(action, input, record, target, sessions);
|
||||
}
|
||||
}
|
||||
if (events != null) events.resolved(action, attempt.accepted ? "accepted" : !completed ? "failed"
|
||||
|
||||
@@ -17,25 +17,38 @@ import org.bukkit.Location;
|
||||
/** Feature-owned acknowledged passive mutations and Quicker Regeneration configuration records. */
|
||||
public final class PassiveEventBridge {
|
||||
public record Action(UUID actionId,EventInput.Context context,String passive,boolean learn,
|
||||
PassiveDefinition definition,CharacterRecord before){
|
||||
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);}
|
||||
PassiveDefinition definition,CharacterRecord before,Map<String,Object> actorMetadata){
|
||||
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);actorMetadata=Map.copyOf(actorMetadata);if(context==null&&!games.dmg.fantasystick.passive.StickKnockback.ID.equals(passive))throw new NullPointerException("Passive actor context");}
|
||||
public Action(UUID actionId,EventInput.Context context,String passive,boolean learn,PassiveDefinition definition,CharacterRecord before){this(actionId,context,passive,learn,definition,before,Map.of());}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public PassiveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);return action;}
|
||||
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
|
||||
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=captureAction(input.player(),input.session(),before,definition,learn);emit("action.observed",action,"observed",null,null);return action;}
|
||||
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=captureAction(player,active,before,definition,learn);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
|
||||
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null);}
|
||||
public void resolved(Action action,PassiveEngine.Mutation mutation){if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.PassiveMutationStatus.APPLIED&&mutation.record()!=null){emit("action.resolved",action,"accepted","acknowledged",mutation.record());emit(action.learn()?"passive.learned":"passive.unlearned",action,"accepted","acknowledged",mutation.record());}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record());}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
|
||||
|
||||
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);var statValues=new LinkedHashMap<String,Object>();action.definition().requirements().keySet().stream().sorted().forEach(stat->{var value=before.stats().get(stat);if(value!=null)statValues.put(stat.name(),value.toString());});data.put("stat_values_before",statValues);data.put("passive_slots_before",before.passives());data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());data.put("passive_slots_after",after.passives());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);var statValues=new LinkedHashMap<String,Object>();action.definition().requirements().keySet().stream().sorted().forEach(stat->{var value=before.stats().get(stat);if(value!=null)statValues.put(stat.name(),value.toString());});data.put("stat_values_before",statValues);data.put("passive_slots_before",before.passives());data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);data.putAll(action.actorMetadata());if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());data.put("passive_slots_after",after.passives());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value,String application){config("config.read",actorKind,player,playerName,key,null,value,application);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after,String application){config("config.changed",actorKind,player,playerName,key,before,after,application);}
|
||||
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){config(type,actorKind,player,playerName,key,before,after,"future_recovery_pulses");}
|
||||
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after,String application){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",application);data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
}
|
||||
private Action captureAction(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn){
|
||||
if(!games.dmg.fantasystick.passive.StickKnockback.ID.equals(definition.id()))return new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_player_id",active.player().toString());data.put("actor_character_id",before.id().toString());data.put("actor_session_id",active.session().toString());data.put("session_id",active.session().toString());
|
||||
String playerName=null;EventInput.Location captured=null;
|
||||
try{playerName=player.getName();}catch(RuntimeException ignored){}
|
||||
data.put("actor_name_capture_quality",playerName==null?"unavailable":"exact");if(playerName!=null)data.put("actor_player_name",playerName);
|
||||
try{captured=location(player.getLocation());}catch(RuntimeException ignored){}
|
||||
data.put("location_capture_quality",captured==null?"unavailable":"exact");
|
||||
if(captured!=null)data.put("actor_location",Map.of("world_id",captured.worldId().toString(),"world_name",captured.worldName(),"x",captured.x(),"y",captured.y(),"z",captured.z(),"captured_at",captured.capturedAt().toString()));
|
||||
var context=playerName==null?null:new EventInput.Context(active.player(),playerName,before.id(),before.name(),active.session(),captured);
|
||||
return new Action(UUID.randomUUID(),context,definition.id(),learn,definition,before,data);
|
||||
}
|
||||
private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
|
||||
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
|
||||
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:passive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.passive.StickKnockback;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Set;
|
||||
import java.time.Instant;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.util.Vector;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Knockback-owned immutable audit intents; replay never touches controls, entities or gameplay. */
|
||||
public final class StickKnockbackEventBridge implements AutoCloseable, Listener {
|
||||
private final FeatureEventOutbox outbox;
|
||||
// Server-thread invocation scopes. The first callbacks belong to this exact native call;
|
||||
// nested external/native calls cannot replace already-bound observations.
|
||||
private final ArrayDeque<Pending> scopes = new ArrayDeque<>();
|
||||
private static final StackWalker NATIVE_STACK = StackWalker.getInstance();
|
||||
private static final class Pending {
|
||||
final UUID action;
|
||||
final UUID target;
|
||||
final EventInput.Context actor;
|
||||
final Map<String, Object> data;
|
||||
long nativeDepth;
|
||||
org.bukkit.event.entity.EntityKnockbackEvent bukkit;
|
||||
io.papermc.paper.event.entity.EntityKnockbackEvent paper;
|
||||
Pending(UUID action, UUID target, EventInput.Context actor, Map<String, Object> data) {
|
||||
this.action = action; this.target = target; this.actor = actor; this.data = Map.copyOf(data);
|
||||
}
|
||||
}
|
||||
|
||||
public StickKnockbackEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Path data, Consumer<Throwable> failures) {
|
||||
outbox = new FeatureEventOutbox(data, "stick-knockback-event-intents",
|
||||
Set.of("passive.learned", "passive.unlearned", "effect.applied"), sink, failures);
|
||||
}
|
||||
|
||||
/** Called only after PassiveEngine has confirmed save and native control publication. */
|
||||
public void acknowledgedPassive(EventInput input) {
|
||||
if (!StickKnockback.ID.equals(input.data().get("passive_id"))
|
||||
|| !(input.type().equals("passive.learned") || input.type().equals("passive.unlearned"))) return;
|
||||
var data = new LinkedHashMap<>(input.data());
|
||||
var before = new BigInteger((String) data.get("points_before"));
|
||||
var after = new BigInteger((String) data.get("points_after"));
|
||||
data.put("point_delta", after.subtract(before).toString());
|
||||
data.put("operation_id", input.actionId().toString());
|
||||
if (input.context() != null) data.put("session_id", input.context().sessionId().toString());
|
||||
data.put("control_refresh_result", "acknowledged");
|
||||
data.put("installed_knockback_tier", input.type().equals("passive.learned") ? 1 : 0);
|
||||
outbox.accepted(new EventInput(input.id(), input.actionId(), input.time(), input.type(), input.context(), data));
|
||||
}
|
||||
|
||||
/** Observe, never decide, the existing native passive application after accepted Basic Attack. */
|
||||
public void apply(BasicAttackEventBridge.Action action, ControlInput input, CharacterRecord record,
|
||||
LivingEntity target, EquipmentSessions sessions) {
|
||||
Pending pending = null;
|
||||
try { pending = capture(action, input, record, target, sessions); }
|
||||
catch (RuntimeException ignored) { /* Audit capture has no gameplay authority. */ }
|
||||
if (pending != null) scopes.push(pending);
|
||||
boolean returned = false;
|
||||
try { StickKnockback.apply(input.player(), target); returned = true; }
|
||||
finally {
|
||||
if (pending != null) {
|
||||
scopes.removeFirstOccurrence(pending);
|
||||
if (returned) {
|
||||
try { applied(pending, target); }
|
||||
catch (RuntimeException ignored) { /* Do not undo or interrupt accepted native gameplay. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void observedBukkit(org.bukkit.event.entity.EntityKnockbackEvent event) {
|
||||
var pending = scopes.peek();
|
||||
try {
|
||||
if (pending != null && pending.bukkit == null && pending.target.equals(event.getEntity().getUniqueId())
|
||||
&& event.getCause() == org.bukkit.event.entity.EntityKnockbackEvent.KnockbackCause.UNKNOWN) {
|
||||
pending.nativeDepth = nativeDepth(); pending.bukkit = event;
|
||||
}
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void observedPaper(io.papermc.paper.event.entity.EntityKnockbackEvent event) {
|
||||
var pending = scopes.peek();
|
||||
try {
|
||||
if (pending != null && pending.bukkit != null && pending.paper == null
|
||||
&& pending.target.equals(event.getEntity().getUniqueId())
|
||||
&& event.getCause() == io.papermc.paper.event.entity.EntityKnockbackEvent.Cause.UNKNOWN
|
||||
&& pending.nativeDepth == nativeDepth()) pending.paper = event;
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private static long nativeDepth() {
|
||||
return NATIVE_STACK.walk(frames -> frames.filter(frame -> frame.getMethodName().equals("knockback")).count());
|
||||
}
|
||||
|
||||
private Pending capture(BasicAttackEventBridge.Action action, ControlInput input, CharacterRecord record,
|
||||
LivingEntity target, EquipmentSessions sessions) {
|
||||
var operation = action == null ? UUID.randomUUID() : action.id();
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("passive_id", StickKnockback.ID); data.put("action_kind", "basic_attack_knockback");
|
||||
data.put("operation_id", operation.toString()); data.put("effect_id", id(operation).toString());
|
||||
data.put("source_player_id", input.session().player().toString());
|
||||
data.put("source_character_id", record.id().toString());
|
||||
data.put("source_session_id", input.session().session().toString());
|
||||
data.put("source_revision", record.revision().toString());
|
||||
if (action != null) data.put("damage_event_id", UUID.nameUUIDFromBytes(
|
||||
("fantasystick:basic-attack:" + action.id() + ":damage").getBytes(StandardCharsets.UTF_8)).toString());
|
||||
else data.put("damage_event_id_status", "unavailable_basic_attack_audit");
|
||||
var targetId = target.getUniqueId(); data.put("target_entity_id", targetId.toString());
|
||||
try { data.put("target_entity_type", target.getType().name().toLowerCase(java.util.Locale.ROOT)); }
|
||||
catch (RuntimeException ignored) { data.put("target_entity_type_status", "unavailable"); }
|
||||
if (target instanceof org.bukkit.entity.Player) {
|
||||
data.put("target_player_id", targetId.toString());
|
||||
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
|
||||
if (active != null) {
|
||||
data.put("target_character_id", active.character().toString());
|
||||
data.put("target_session_id", active.session().toString());
|
||||
} else data.put("target_character_status", "unavailable");
|
||||
}
|
||||
boolean sprinting = input.player().isSprinting();
|
||||
data.put("sprinting", sprinting); data.put("strength", sprinting ? 2d : 1d); data.put("knockback_tier", 1);
|
||||
location(data, "source_location", input.player()::getLocation);
|
||||
location(data, "target_location", target::getLocation);
|
||||
velocity(data, "velocity_before", target);
|
||||
try {
|
||||
var attribute = target.getAttribute(org.bukkit.attribute.Attribute.KNOCKBACK_RESISTANCE);
|
||||
double value = attribute == null ? Double.NaN : attribute.getValue();
|
||||
if (Double.isFinite(value)) data.put("knockback_resistance", value);
|
||||
else data.put("knockback_resistance_status", "unavailable");
|
||||
} catch (RuntimeException ignored) { data.put("knockback_resistance_status", "unavailable"); }
|
||||
EventInput.Context context = action == null ? null : action.actor();
|
||||
return new Pending(operation, targetId, context, data);
|
||||
}
|
||||
|
||||
private void applied(Pending pending, LivingEntity target) {
|
||||
// CraftEventFactory dispatches Bukkit first, then Paper. Native velocity is written only
|
||||
// after the FINAL Paper result returns. Do not report attempted/cancelled/resisted/no-op pushes.
|
||||
if (pending.bukkit == null || pending.paper == null || pending.paper.isCancelled()
|
||||
|| !Double.isFinite(pending.bukkit.getForce()) || pending.bukkit.getForce() <= 0) return;
|
||||
var delta = pending.paper.getKnockback();
|
||||
if (!finite(delta) || delta.lengthSquared() == 0) return;
|
||||
var data = new LinkedHashMap<>(pending.data);
|
||||
data.put("native_force", pending.bukkit.getForce()); data.put("native_cause", pending.paper.getCause().name());
|
||||
data.put("bukkit_cancelled", pending.bukkit.isCancelled()); data.put("native_final_cancelled", false);
|
||||
data.put("native_velocity_delta", vector(delta));
|
||||
velocity(data, "velocity_after", target); data.put("outcome", "accepted");
|
||||
data.put("native_boundary", "returned_after_final_paper_knockback_and_velocity_application");
|
||||
outbox.accepted(new EventInput(id(pending.action), pending.action, Instant.now(), "effect.applied", pending.actor, data));
|
||||
}
|
||||
|
||||
private static UUID id(UUID action) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:stick-knockback:" + action + ":applied").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
private static void location(Map<String, Object> data, String key, java.util.function.Supplier<org.bukkit.Location> capture) {
|
||||
try {
|
||||
var location = capture.get(); var world = location == null ? null : location.getWorld();
|
||||
if (world == null || !Double.isFinite(location.getX()) || !Double.isFinite(location.getY()) || !Double.isFinite(location.getZ())) {
|
||||
data.put(key + "_status", "unavailable"); return;
|
||||
}
|
||||
data.put(key, Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ(), "captured_at", Instant.now().toString()));
|
||||
data.put(key + "_status", "exact");
|
||||
} catch (RuntimeException ignored) { data.put(key + "_status", "unavailable"); }
|
||||
}
|
||||
private static void velocity(Map<String, Object> data, String key, LivingEntity target) {
|
||||
try { var value = target.getVelocity(); if (finite(value)) data.put(key, vector(value));
|
||||
else data.put(key + "_status", "unavailable"); }
|
||||
catch (RuntimeException ignored) { data.put(key + "_status", "unavailable"); }
|
||||
}
|
||||
private static boolean finite(Vector value) {
|
||||
return value != null && Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
|
||||
}
|
||||
private static Map<String, Object> vector(Vector value) { return Map.of("x", value.getX(), "y", value.getY(), "z", value.getZ()); }
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -1469,6 +1469,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getItemFactory" -> org.bukkit.craftbukkit.inventory.CraftItemFactory.instance();
|
||||
case "getScheduler" -> scheduler;
|
||||
case "getLogger" -> Logger.getLogger("FantasyStick lifecycle fixture");
|
||||
case "getWarningState" -> org.bukkit.Warning.WarningState.DEFAULT;
|
||||
case "getName" -> "Purpur";
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.bukkit.entity.EntityType;
|
||||
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 StickKnockbackEffectNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredPositiveBasicHitAndActualPinnedNativeKnockbackExportOneCausalEffect() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("stick_knockback", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1); actor.core.location.setYaw(0);
|
||||
var target = new NativeTarget(f, actor, 0.25);
|
||||
var before = CombatExperienceNativeTest.record(f, actor);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target.entity, true));
|
||||
assertEquals(0, target.velocity.get().x, 0.000001); assertEquals(0.4, target.velocity.get().y, 0.000001);
|
||||
assertEquals(0.75, target.velocity.get().z, 0.000001,
|
||||
"Use actual pinned CraftLivingEntity/native resistance and velocity application, not a manufactured successful event");
|
||||
assertEquals(before, CombatExperienceNativeTest.record(f, actor));
|
||||
ReskillEventsNativeTest.await(f, () -> StickKnockbackEventsNativeTest.rows(data, "effect.applied").size() == 1);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); drain(f);
|
||||
var rows = StickKnockbackEventsNativeTest.rows(data, "effect.applied");
|
||||
assertEquals(1, rows.size(), "Accepted registered Basic Attack and actual native Knockback must reach its sole causal effect record");
|
||||
var row = rows.getFirst(); var values = row.getAsJsonObject("data");
|
||||
var damage = ReskillEventsNativeTest.records(data).stream().filter(value -> value.get("type").getAsString().endsWith("combat.damage")
|
||||
&& value.getAsJsonObject("data").has("action_kind")
|
||||
&& value.getAsJsonObject("data").get("action_kind").getAsString().equals("basic_attack")).toList();
|
||||
assertEquals(1, damage.size(), "Knockback does not create a second damage producer");
|
||||
assertEquals(damage.getFirst().getAsJsonObject("data").get("action_id").getAsString(),
|
||||
values.get("action_id").getAsString());
|
||||
assertEquals(damage.getFirst().get("id").getAsString(), values.get("damage_event_id").getAsString());
|
||||
assertEquals(actor.core.id.toString(), row.get("playerid").getAsString());
|
||||
assertEquals(before.id().toString(), row.get("characterid").getAsString());
|
||||
assertEquals(target.id.toString(), values.get("target_entity_id").getAsString());
|
||||
assertEquals(1d, values.get("strength").getAsDouble()); assertEquals(1, values.get("knockback_tier").getAsInt());
|
||||
assertFalse(values.get("sprinting").getAsBoolean());
|
||||
assertEquals(0.25, values.get("knockback_resistance").getAsDouble());
|
||||
assertEquals(0.75, values.get("native_force").getAsDouble());
|
||||
assertEquals(0.75, values.getAsJsonObject("velocity_after").get("z").getAsDouble());
|
||||
assertEquals("accepted", values.get("outcome").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"sprint", "bukkit_cancelled", "paper_cancelled", "resisted", "zero_vector", "modified", "nested"})
|
||||
void finalNativeCancellationResistanceSprintAndModifiedVelocityAreTruthful(String mode) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("stick_knockback", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1); actor.core.location.setYaw(0);
|
||||
actor.sprinting = mode.equals("sprint");
|
||||
var target = new NativeTarget(f, actor, mode.equals("resisted") ? 1 : 0);
|
||||
f.dispatcher.register(new org.bukkit.event.Listener() {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void bukkit(org.bukkit.event.entity.EntityKnockbackEvent event) {
|
||||
if (mode.equals("bukkit_cancelled")) event.setCancelled(true);
|
||||
if (mode.equals("nested") && event.getForce() == 1d) target.entity.knockback(0.2, 1, 0);
|
||||
}
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void paper(io.papermc.paper.event.entity.EntityKnockbackEvent event) {
|
||||
if (mode.equals("paper_cancelled") || mode.equals("nested") && event.getKnockback().getX() < 0) event.setCancelled(true);
|
||||
if (mode.equals("zero_vector")) event.setKnockback(new org.bukkit.util.Vector());
|
||||
if (mode.equals("modified")) event.setKnockback(new org.bukkit.util.Vector(0.125, 0.2, 0.375));
|
||||
}
|
||||
}, f.plugin);
|
||||
var before = CombatExperienceNativeTest.record(f, actor);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target.entity, true);
|
||||
f.dispatcher.accept(attack); f.dispatcher.accept(attack);
|
||||
assertFalse(actor.sprinting, "Preserve native sprint consumption, not audit-dependent sprint state");
|
||||
assertEquals(before, CombatExperienceNativeTest.record(f, actor));
|
||||
verify(target.entity, times(mode.equals("nested") ? 2 : 1)).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
if (mode.equals("sprint")) assertEquals(2, target.velocity.get().z, 0.000001);
|
||||
else if (mode.equals("modified")) assertEquals(new Vec3(0.125, 0.2, 0.375), target.velocity.get());
|
||||
else if (mode.equals("nested")) assertEquals(new Vec3(0, 0.4, 1), target.velocity.get());
|
||||
else assertEquals(Vec3.ZERO, target.velocity.get());
|
||||
if (mode.equals("sprint") || mode.equals("modified") || mode.equals("nested"))
|
||||
ReskillEventsNativeTest.await(f, () -> StickKnockbackEventsNativeTest.rows(data, "effect.applied").size() == 1);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); drain(f);
|
||||
var rows = StickKnockbackEventsNativeTest.rows(data, "effect.applied");
|
||||
assertEquals(mode.equals("sprint") || mode.equals("modified") || mode.equals("nested") ? 1 : 0, rows.size());
|
||||
if (!rows.isEmpty()) {
|
||||
var values = rows.getFirst().getAsJsonObject("data");
|
||||
assertEquals(mode.equals("sprint"), values.get("sprinting").getAsBoolean());
|
||||
assertEquals(mode.equals("sprint") ? 2 : 1, values.get("strength").getAsDouble());
|
||||
assertEquals(target.velocity.get().z, values.getAsJsonObject("velocity_after").get("z").getAsDouble());
|
||||
assertFalse(values.get("native_final_cancelled").getAsBoolean());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"cancelled", "zero", "nonfinite", "stale"})
|
||||
void unacceptedDamageAndStaleInputsCannotClaimPassiveSuccess(String mode) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("stick_knockback", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor); actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = new NativeTarget(f, actor, 0);
|
||||
f.dispatcher.register(new org.bukkit.event.Listener() {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void damage(EntityDamageByEntityEvent event) {
|
||||
if (event.getEntity() != target.entity) return;
|
||||
if (mode.equals("cancelled")) event.setCancelled(true);
|
||||
if (mode.equals("zero")) event.setDamage(0);
|
||||
if (mode.equals("nonfinite")) event.setDamage(Double.NaN);
|
||||
if (mode.equals("stale")) actor.core.inventory.setItemInOffHand(null);
|
||||
}
|
||||
}, f.plugin);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target.entity, true));
|
||||
verify(target.entity, never()).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
assertEquals(Vec3.ZERO, target.velocity.get());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); drain(f);
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "effect.applied").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "stickKnockbackAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/** Only damage input is controlled; Craft/native knockback dispatch, resistance and velocity math are real. */
|
||||
static final class NativeTarget {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final AtomicReference<Vec3> velocity = new AtomicReference<>(Vec3.ZERO);
|
||||
final org.bukkit.craftbukkit.entity.CraftZombie entity = mock(org.bukkit.craftbukkit.entity.CraftZombie.class);
|
||||
final net.minecraft.world.entity.monster.zombie.Zombie nativeEntity = mock(net.minecraft.world.entity.monster.zombie.Zombie.class);
|
||||
NativeTarget(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor, double resistance) throws ReflectiveOperationException {
|
||||
var nativeField = org.bukkit.craftbukkit.entity.CraftEntity.class.getDeclaredField("entity");
|
||||
nativeField.setAccessible(true); nativeField.set(entity, nativeEntity);
|
||||
f.platform.when(org.bukkit.Bukkit::getPluginManager).thenReturn(f.server.getPluginManager());
|
||||
var sources = mock(net.minecraft.world.damagesource.DamageSources.class);
|
||||
when(sources.generic()).thenReturn(mock(net.minecraft.world.damagesource.DamageSource.class));
|
||||
when(nativeEntity.damageSources()).thenReturn(sources); when(entity.getHandle()).thenReturn(nativeEntity);
|
||||
when(nativeEntity.getBukkitEntity()).thenReturn(entity); when(nativeEntity.getUUID()).thenReturn(id);
|
||||
when(nativeEntity.getAttributeValue(net.minecraft.world.entity.ai.attributes.Attributes.KNOCKBACK_RESISTANCE)).thenReturn(resistance);
|
||||
when(nativeEntity.getDeltaMovement()).thenAnswer(call -> velocity.get()); when(nativeEntity.onGround()).thenReturn(true);
|
||||
doAnswer(call -> { velocity.set(call.getArgument(0)); return null; }).when(nativeEntity).setDeltaMovement(any(Vec3.class));
|
||||
doCallRealMethod().when(entity).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
doCallRealMethod().when(nativeEntity).knockback(anyDouble(), anyDouble(), anyDouble(),
|
||||
any(net.minecraft.world.damagesource.DamageSource.class), anyFloat());
|
||||
doCallRealMethod().when(nativeEntity).knockback(anyDouble(), anyDouble(), anyDouble(),
|
||||
any(net.minecraft.world.damagesource.DamageSource.class), anyFloat(), nullable(net.minecraft.world.entity.Entity.class),
|
||||
any(io.papermc.paper.event.entity.EntityKnockbackEvent.Cause.class));
|
||||
doCallRealMethod().when(nativeEntity).knockback(anyDouble(), anyDouble(), anyDouble(),
|
||||
any(net.minecraft.world.damagesource.DamageSource.class), anyFloat(), anyBoolean(), nullable(net.minecraft.world.entity.Entity.class),
|
||||
any(io.papermc.paper.event.entity.EntityKnockbackEvent.Cause.class));
|
||||
when(entity.getUniqueId()).thenReturn(id); when(entity.getName()).thenReturn("Native Knockback Target");
|
||||
when(entity.getType()).thenReturn(EntityType.ZOMBIE); when(entity.getWorld()).thenReturn(actor.core.location.getWorld());
|
||||
when(entity.getLocation()).thenReturn(new org.bukkit.Location(actor.core.location.getWorld(), 16, 70, -8));
|
||||
when(entity.getVelocity()).thenAnswer(call -> new org.bukkit.util.Vector(velocity.get().x, velocity.get().y, velocity.get().z));
|
||||
var attribute = mock(org.bukkit.attribute.AttributeInstance.class); when(attribute.getValue()).thenReturn(resistance);
|
||||
when(entity.getAttribute(org.bukkit.attribute.Attribute.KNOCKBACK_RESISTANCE)).thenReturn(attribute);
|
||||
when(entity.getHealth()).thenReturn(20d);
|
||||
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
doAnswer(call -> {
|
||||
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, entity, DamageCause.ENTITY_ATTACK, source, call.getArgument(0));
|
||||
f.dispatcher.accept(event); return null;
|
||||
}).when(entity).damage(anyDouble(), same(actor.player));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class StickKnockbackEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void actualBookLearnAndRefundExportOnlyAcknowledgedAuthenticControlRefreshes() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, actor, List.of("", "", "")); f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
var before = CombatExperienceNativeTest.record(f, actor);
|
||||
PartyBookNativeTest.open(f, actor);
|
||||
PartyBookNativeTest.click(f, actor, SpeedINativeTest.action(actor, "[Learn]", "stick_knockback"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var learned = CombatExperienceNativeTest.record(f, actor);
|
||||
assertEquals(List.of("stick_knockback", "", ""), learned.passives());
|
||||
assertEquals(before.unspentPoints().subtract(BigInteger.ONE), learned.unspentPoints());
|
||||
assertEquals(1, actor.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
ReskillEventsNativeTest.await(f, () -> rows(data, "passive.learned").size() == 1);
|
||||
var rows = rows(data, "passive.learned");
|
||||
assertEquals(1, rows.size(), "Actual Knockback book save and authentic stick refresh must reach the owning JSONL record");
|
||||
assertMutation(rows.getFirst(), actor, before, learned, "-1", 1);
|
||||
PartyBookNativeTest.open(f, actor);
|
||||
PartyBookNativeTest.click(f, actor, SpeedINativeTest.action(actor, "[Unlearn]", "stick_knockback"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = CombatExperienceNativeTest.record(f, actor);
|
||||
assertEquals(before.unspentPoints(), refunded.unspentPoints());
|
||||
assertEquals(List.of("", "", ""), refunded.passives());
|
||||
assertEquals(0, actor.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
ReskillEventsNativeTest.await(f, () -> rows(data, "passive.unlearned").size() == 1);
|
||||
assertMutation(rows(data, "passive.unlearned").getFirst(), actor, learned, refunded, "1", 0);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
assertEquals(1, rows(data, "passive.learned").size()); assertEquals(1, rows(data, "passive.unlearned").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailablePassiveAuditNameCannotPreventActualAcknowledgedLearning() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, actor, List.of("", "", "")); f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
PartyBookNativeTest.open(f, actor); var learn = SpeedINativeTest.action(actor, "[Learn]", "stick_knockback");
|
||||
String originalName = actor.player.getName();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
boolean audit = StackWalker.getInstance().walk(frames -> frames.anyMatch(frame ->
|
||||
frame.getClassName().equals("games.dmg.fantasystick.events.PassiveEventBridge")));
|
||||
if (audit) throw new IllegalStateException("Unavailable owner audit name");
|
||||
return originalName;
|
||||
}).when(actor.player).getName();
|
||||
PartyBookNativeTest.click(f, actor, learn); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(CombatExperienceNativeTest.record(f, actor).passives().contains("stick_knockback"),
|
||||
"Owner audit name failure cannot prevent the approved book purchase and native control refresh");
|
||||
assertEquals(1, actor.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
ReskillEventsNativeTest.await(f, () -> rows(data, "passive.learned").size() == 1);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); StickKnockbackEffectNativeTest.drain(f);
|
||||
var values = rows(data, "passive.learned").getFirst().getAsJsonObject("data");
|
||||
assertEquals("unavailable", values.get("actor_name_capture_quality").getAsString());
|
||||
assertEquals(actor.core.id.toString(), values.get("actor_player_id").getAsString());
|
||||
assertTrue(values.has("actor_location"), "Unavailable audit name cannot discard the independently captured exact actor location");
|
||||
assertEquals(12d, values.getAsJsonObject("actor_location").get("x").getAsDouble());
|
||||
assertEquals("acknowledged", values.get("control_refresh_result").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats()); stats.put(Stat.STR, BigInteger.valueOf(12));
|
||||
repository.replace(new 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(), old.dead(), old.deathId(), old.diedAt(), old.coins(),
|
||||
old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration()), old.revision());
|
||||
}
|
||||
static List<JsonObject> rows(Path data, String type) {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(type)
|
||||
&& row.getAsJsonObject("data").has("passive_id")
|
||||
&& row.getAsJsonObject("data").get("passive_id").getAsString().equals("stick_knockback")).toList(); }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
private static void assertMutation(JsonObject row, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
CharacterRecord before, CharacterRecord after, String delta, int enchantment) {
|
||||
var values = row.getAsJsonObject("data"); assertEquals(actor.core.id.toString(), row.get("playerid").getAsString());
|
||||
assertEquals(after.id().toString(), row.get("characterid").getAsString()); assertTrue(values.has("session_id"));
|
||||
assertEquals(before.revision().toString(), values.get("before_revision").getAsString());
|
||||
assertEquals(after.revision().toString(), values.get("after_revision").getAsString());
|
||||
assertEquals(delta, values.get("point_delta").getAsString()); assertEquals(0, values.get("passive_slot").getAsInt());
|
||||
assertEquals("acknowledged", values.get("control_refresh_result").getAsString());
|
||||
assertEquals(enchantment, values.get("installed_knockback_tier").getAsInt());
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class StickKnockbackNativeTest {
|
||||
String refused = refusedPages.get(refusedDetail.page() - 1).getString().replaceAll("\\s+", "");
|
||||
assertTrue(refused.contains("STR12")); assertNull(findCustom(refusedPages.get(refusedDetail.page() - 1),
|
||||
"fantasystick:passive_unlock"));
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "passive.learned").isEmpty());
|
||||
ineligible.core.inventory.setItemInOffHand(null); f.changed(ineligible); f.awaitCharacterAndReceipt(ineligible);
|
||||
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12, List.of("", "", ""));
|
||||
@@ -48,6 +49,7 @@ class StickKnockbackNativeTest {
|
||||
assertEquals(List.of("stick_knockback", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
assertEquals(1, actor.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
ReskillEventsNativeTest.await(f, () -> StickKnockbackEventsNativeTest.rows(data, "passive.learned").size() == 1);
|
||||
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = target();
|
||||
@@ -63,12 +65,18 @@ class StickKnockbackNativeTest {
|
||||
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));
|
||||
assertEquals(1, StickKnockbackEventsNativeTest.rows(data, "passive.learned").size(),
|
||||
"Actual exit/transfer/control reconciliation cannot invent another learning record");
|
||||
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));
|
||||
ReskillEventsNativeTest.await(f, () -> StickKnockbackEventsNativeTest.rows(data, "passive.unlearned").size() == 1);
|
||||
var refund = StickKnockbackEventsNativeTest.rows(data, "passive.unlearned").getFirst();
|
||||
assertEquals(holder.core.id.toString(), refund.get("playerid").getAsString());
|
||||
assertEquals(learned.id().toString(), refund.get("characterid").getAsString());
|
||||
holder.core.inventory.setItemInOffHand(null); f.changed(holder); f.awaitCharacterAndReceipt(holder);
|
||||
assertTrue(holder.core.inventory.getItem(1) == null || holder.core.inventory.getItem(1).isEmpty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.events.StickKnockbackEventBridge;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class StickKnockbackReplayNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void realRejectedLearnNativePushAndRefundReplayEveryOriginalPropertyOfflineWithoutGameplay() throws Exception {
|
||||
List<Properties> originals;
|
||||
FantasyStickLifecycleNativeTest.Actor actor;
|
||||
StickKnockbackEffectNativeTest.NativeTarget target;
|
||||
games.dmg.fantasystick.persistence.CharacterRecord saved;
|
||||
List<org.bukkit.inventory.ItemStack> inventory;
|
||||
int messages;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
PartyBookNativeTest.open(f, actor);
|
||||
PartyBookNativeTest.click(f, actor, SpeedINativeTest.action(actor, "[Learn]", "stick_knockback"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(CombatExperienceNativeTest.record(f, actor).passives().contains("stick_knockback"));
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1); actor.core.location.setYaw(0);
|
||||
target = new StickKnockbackEffectNativeTest.NativeTarget(f, actor, 0);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target.entity, true));
|
||||
assertEquals(1, target.velocity.get().z, 0.000001);
|
||||
actor.core.location.setX(999); actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, actor);
|
||||
PartyBookNativeTest.click(f, actor, SpeedINativeTest.action(actor, "[Unlearn]", "stick_knockback"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
saved = CombatExperienceNativeTest.record(f, actor); assertEquals(List.of("", "", ""), saved.passives());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); StickKnockbackEffectNativeTest.drain(f);
|
||||
originals = WitchAuditTestSupport.intents(data.resolve("stick-knockback-event-intents"));
|
||||
assertEquals(3, originals.size());
|
||||
assertEquals(java.util.Set.of("passive.learned", "effect.applied", "passive.unlearned"),
|
||||
originals.stream().map(value -> value.getProperty("type")).collect(java.util.stream.Collectors.toSet()));
|
||||
inventory = snapshot(actor); messages = actor.messageComponents.size();
|
||||
}
|
||||
clearInvocationsTyped(actor.player, target.entity, target.nativeEntity);
|
||||
var failures = new java.util.ArrayList<Throwable>();
|
||||
var warnings = new java.util.ArrayList<String>();
|
||||
var log = LocalEventLog.open(data, Clock.systemUTC(),
|
||||
new LocalEventLog.RuntimeInfo("US-059 replay", "Purpur 2618", "26.2", "test"), 256, 1_048_576L, warnings::add);
|
||||
log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var bridge = new StickKnockbackEventBridge(log::emit, data, failures::add);
|
||||
bridge.replay(); bridge.replay(); bridge.close(); bridge.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
log.close(); assertTrue(failures.isEmpty(), failures.toString());
|
||||
assertTrue(warnings.isEmpty(), warnings.toString());
|
||||
var exported = ReskillEventsNativeTest.records(data).stream().filter(value -> value.getAsJsonObject("data").has("passive_id")
|
||||
&& value.getAsJsonObject("data").get("passive_id").getAsString().equals("stick_knockback")
|
||||
&& (value.get("type").getAsString().endsWith("passive.learned") || value.get("type").getAsString().endsWith("passive.unlearned")
|
||||
|| value.get("type").getAsString().endsWith("effect.applied"))).toList();
|
||||
assertEquals(3, exported.size());
|
||||
for (var original : originals) assertOriginalCollections(original,
|
||||
exported.stream().filter(value -> value.get("id").getAsString().equals(original.getProperty("id"))).findFirst().orElseThrow());
|
||||
assertEquals(saved, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(saved.id()).orElseThrow());
|
||||
assertEquals(inventory, snapshot(actor)); assertEquals(messages, actor.messageComponents.size());
|
||||
assertEquals(1, target.velocity.get().z, 0.000001);
|
||||
verifyNoInteractions(actor.player, target.entity, target.nativeEntity);
|
||||
assertTrue(WitchAuditTestSupport.intents(data.resolve("stick-knockback-event-intents")).isEmpty());
|
||||
}
|
||||
|
||||
@Test void genuineUnwritableOwnerIntentCannotInterruptLearnAcceptedNativePushOrRefund() throws Exception {
|
||||
Files.writeString(data.resolve("stick-knockback-event-intents"), "not a directory");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
PartyBookNativeTest.open(f, actor); PartyBookNativeTest.click(f, actor, SpeedINativeTest.action(actor, "[Learn]", "stick_knockback"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(CombatExperienceNativeTest.record(f, actor).passives().contains("stick_knockback"));
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1); actor.core.location.setYaw(0);
|
||||
var target = new StickKnockbackEffectNativeTest.NativeTarget(f, actor, 0);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target.entity, true));
|
||||
assertEquals(1, target.velocity.get().z, 0.000001);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0);
|
||||
PartyBookNativeTest.open(f, actor); PartyBookNativeTest.click(f, actor, SpeedINativeTest.action(actor, "[Unlearn]", "stick_knockback"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(List.of("", "", ""), CombatExperienceNativeTest.record(f, actor).passives());
|
||||
assertEquals(java.math.BigInteger.valueOf(40), CombatExperienceNativeTest.record(f, actor).unspentPoints());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); StickKnockbackEffectNativeTest.drain(f);
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "effect.applied").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertOriginalCollections(Properties original, com.google.gson.JsonObject row) {
|
||||
var normalized = new Properties(); normalized.putAll(original);
|
||||
for (var key : original.stringPropertyNames()) if (key.startsWith("data.") && original.getProperty(key).startsWith("l")) {
|
||||
var array = new com.google.gson.JsonArray(); var text = original.getProperty(key).substring(1);
|
||||
if (!text.isEmpty()) for (var value : text.split(",")) array.add(value);
|
||||
normalized.setProperty(key, "j" + array);
|
||||
}
|
||||
WitchAuditTestSupport.assertOriginal(normalized, row);
|
||||
}
|
||||
private static List<org.bukkit.inventory.ItemStack> snapshot(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
return java.util.Arrays.stream(actor.core.inventory.getContents()).map(value -> value == null ? null : value.clone()).toList();
|
||||
}
|
||||
private static void clearInvocationsTyped(Object... objects) { org.mockito.Mockito.<Object>clearInvocations(objects); }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class StickKnockbackSafetyNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void realFailedCharacterSaveThenFreshRetryAndDuplicatePacketHaveOneAcknowledgedPurchase() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor); var before = CombatExperienceNativeTest.record(f, actor);
|
||||
PartyBookNativeTest.open(f, actor); var original = SpeedINativeTest.action(actor, "[Learn]", "stick_knockback");
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); PartyBookNativeTest.click(f, actor, original); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(before, CombatExperienceNativeTest.record(f, actor));
|
||||
assertEquals(0, actor.core.inventory.getItem(1).getEnchantmentLevel(org.bukkit.enchantments.Enchantment.KNOCKBACK));
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "passive.learned").isEmpty());
|
||||
}
|
||||
PartyBookNativeTest.open(f, actor); var retry = SpeedINativeTest.action(actor, "[Learn]", "stick_knockback");
|
||||
PartyBookNativeTest.click(f, actor, retry); PartyBookNativeTest.click(f, actor, retry);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
ReskillEventsNativeTest.await(f, () -> StickKnockbackEventsNativeTest.rows(data, "passive.learned").size() == 1);
|
||||
var saved = CombatExperienceNativeTest.record(f, actor);
|
||||
assertEquals(before.unspentPoints().subtract(java.math.BigInteger.ONE), saved.unspentPoints());
|
||||
assertEquals(before.revision().add(java.math.BigInteger.ONE), saved.revision());
|
||||
assertEquals(1, actor.core.inventory.getItem(1).getEnchantmentLevel(org.bukkit.enchantments.Enchantment.KNOCKBACK));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); StickKnockbackEffectNativeTest.drain(f);
|
||||
assertEquals(1, StickKnockbackEventsNativeTest.rows(data, "passive.learned").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedNativeControlPublicationDoesNotFabricateAcknowledgedLearning() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("", "", "")); f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
PartyBookNativeTest.open(f, actor); var learn = SpeedINativeTest.action(actor, "[Learn]", "stick_knockback");
|
||||
doThrow(new IllegalStateException("Real native refresh acknowledgement unavailable")).doNothing().when(actor.player).updateInventory();
|
||||
PartyBookNativeTest.click(f, actor, learn);
|
||||
assertThrows(IllegalStateException.class, () -> PartyBookNativeTest.drain(f),
|
||||
"The actual native refresh fails before a positive acknowledgement can exist");
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertTrue(CombatExperienceNativeTest.record(f, actor).passives().contains("stick_knockback"),
|
||||
"Keep authoritative saved truth distinct from an unconfirmed native acknowledgement");
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "passive.learned").isEmpty());
|
||||
doNothing().when(actor.player).updateInventory(); f.plugin.setEnabled(false); f.awaitWorkers(); StickKnockbackEffectNativeTest.drain(f);
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "passive.learned").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualRegisteredPositiveDummyMeasurementNeverPushesOrCopiesDamageIntoKnockbackHistory() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var ports = TrainingDummyEventsNativeTest.npc(f, data); var actor = ports.actor();
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("stick_knockback", "", ""));
|
||||
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"training-dummy", "place"});
|
||||
ReskillEventsNativeTest.await(f, () -> ports.present().get()); f.activate(actor); actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
doAnswer(call -> {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getDirectEntity()).thenReturn(actor.player); when(source.getCausingEntity()).thenReturn(actor.player);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player, ports.entity(),
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, call.getArgument(0)));
|
||||
return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), same(actor.player));
|
||||
var before = CombatExperienceNativeTest.record(f, actor);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, ports.entity(), true));
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString().endsWith("training_dummy.measured")); }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
verify(ports.entity(), never()).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
assertEquals(before, CombatExperienceNativeTest.record(f, actor));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); StickKnockbackEffectNativeTest.drain(f);
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "effect.applied").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualOrdinaryWeaponAndRegisteredShieldBashNeverCreateBasicPassiveEffects() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("stick_knockback", "", "")); f.players.put(actor.core.id, actor.player);
|
||||
var target = new StickKnockbackEffectNativeTest.NativeTarget(f, actor, 0);
|
||||
// An ordinary, inactive holder can deliver native weapon damage, never this active passive.
|
||||
actor.core.inventory.setItem(8, new org.bukkit.inventory.ItemStack(org.bukkit.Material.IRON_SWORD));
|
||||
actor.core.inventory.getInventory().setSelectedSlot(8);
|
||||
var ordinary = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target.entity, true);
|
||||
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled()); target.entity.damage(2d, actor.player);
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "effect.applied").isEmpty());
|
||||
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.CON, java.math.BigInteger.valueOf(12));
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(),
|
||||
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration()), old.revision());
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); f.activate(actor); PartyBookNativeTest.open(f, actor);
|
||||
PartyBookNativeTest.click(f, actor, SpeedINativeTest.action(actor, "[Learn]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(org.bukkit.Material.SHIELD, actor.core.inventory.getItem(2).getType());
|
||||
when(target.entity.getLocation()).thenReturn(actor.core.location.clone().add(2, 0, 0));
|
||||
doReturn(true).when(actor.player).hasLineOfSight(target.entity);
|
||||
doCallRealMethod().when(target.entity).setVelocity(any(org.bukkit.util.Vector.class));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player, target.entity,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target.entity).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target.entity, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(CombatExperienceNativeTest.record(f, actor).cooldowns().containsKey("shield_bash"), "Actual registered Shield Bash must commit: " + actor.messages);
|
||||
assertTrue(target.velocity.get().x > 0, "Actual accepted Shield Bash velocity: " + actor.messages + "; target=" + target.velocity.get());
|
||||
assertTrue(target.velocity.get().y > 0);
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString().endsWith("effect.applied")
|
||||
&& row.getAsJsonObject("data").has("ability_id") && row.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")); }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); StickKnockbackEffectNativeTest.drain(f);
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "effect.applied").isEmpty(),
|
||||
"Shield Bash retains its own accepted velocity/effect producer, never a Basic Attack passive copy");
|
||||
assertTrue(StickKnockbackEventsNativeTest.rows(data, "passive.learned").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void feedbackOffAndUnavailableOptionalResistanceStillPreserveActualNativePushAndAudit() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
StickKnockbackEventsNativeTest.prepare(data, actor, List.of("stick_knockback", "", "")); f.players.put(actor.core.id, actor.player);
|
||||
f.feedbackCommand.execute(actor.player, "fantasyfeedback", new String[]{"off"}); f.tick(); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1); actor.core.location.setYaw(0);
|
||||
var target = new StickKnockbackEffectNativeTest.NativeTarget(f, actor, 0.25);
|
||||
doThrow(new IllegalStateException("Optional attribute capture unavailable")).when(target.entity)
|
||||
.getAttribute(org.bukkit.attribute.Attribute.KNOCKBACK_RESISTANCE);
|
||||
int messages = actor.messageComponents.size();
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target.entity, true));
|
||||
assertEquals(0.75, target.velocity.get().z, 0.000001); assertEquals(messages, actor.messageComponents.size());
|
||||
ReskillEventsNativeTest.await(f, () -> StickKnockbackEventsNativeTest.rows(data, "effect.applied").size() == 1);
|
||||
var values = StickKnockbackEventsNativeTest.rows(data, "effect.applied").getFirst().getAsJsonObject("data");
|
||||
assertEquals("unavailable", values.get("knockback_resistance_status").getAsString());
|
||||
assertFalse(values.has("knockback_resistance")); assertEquals(0.75, values.get("native_force").getAsDouble());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user