Compare commits

...
9 Commits
Author SHA1 Message Date
dmg fc90af3a62 feat(longbow): audit native draw and arrow damage
Release / release (push) Successful in 17m13s
2026-09-25 22:10:24 -04:00
dmg f6d95ca338 feat(frost-shard): audit owned arrow and slow outcomes
Release / release (push) Successful in 17m59s
2026-09-25 21:52:30 -04:00
dmg db4c3b2842 feat(great-sword): audit charged native strikes
Release / release (push) Successful in 16m41s
2026-09-25 21:22:45 -04:00
dmg 830049b339 feat(nutrition): audit native healing and restoration
Release / release (push) Successful in 17m20s
2026-09-25 20:52:29 -04:00
dmg fcbc2814c2 fix(ci): avoid transient event files in native test scan
Release / release (push) Successful in 14m0s
2026-09-25 17:06:08 -04:00
dmg 897a0606ba fix(adventure): retract owned hook after quest catch
Release / release (push) Failing after 13m17s
2026-09-25 16:39:35 -04:00
dmg c13c3093cf test(combat): await proved basic attack reentry
Release / release (push) Successful in 14m58s
2026-09-25 15:57:40 -04:00
dmg bb8d1857b4 feat(combat): audit accepted basic attacks
Release / release (push) Failing after 7m39s
2026-09-25 15:26:45 -04:00
dmg c7fce1a53d fix(adventure): retain quest hook while fish approaches
Release / release (push) Successful in 8m46s
2026-09-25 15:19:11 -04:00
25 changed files with 3146 additions and 117 deletions

No files matched your search

@@ -124,6 +124,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
private games.dmg.fantasystick.session.AdminRevival adminRevival;
@@ -177,6 +178,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
@@ -188,6 +191,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
private void dispatchOwnedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
var policy = nutritionPolicy;
if (policy == null) getServer().getPluginManager().callEvent(event);
else policy.dispatchOwnedHealing(event, getServer().getPluginManager()::callEvent);
}
@Override public void onEnable() {
try {
Object generation = new Object();
@@ -253,7 +262,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var rewardRules = combatRewardRules(hardMobXp);
var fireballConfiguration = fireballConfiguration();
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
var greatSwordSettings = greatSwordSettings();
var greatSwordConfiguration = greatSwordConfiguration();
var greatSwordInitial = games.dmg.fantasystick.persistence.GreatSwordSettings.parse(greatSwordConfiguration);
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
var longbowConfiguration = longbowConfiguration();
@@ -441,7 +451,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
getDataFolder().toPath(), secondWindConfiguration);
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read);
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
getDataFolder().toPath(), stoneSkinConfiguration);
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
@@ -462,7 +472,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
getDataFolder().toPath(), mendingLightConfiguration);
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
nextTick, event -> getServer().getPluginManager().callEvent(event),
nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
@@ -488,7 +498,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
combatMitigation, nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
particleDelivery);
@@ -498,7 +508,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
greatSwordSettings = new games.dmg.fantasystick.persistence.GreatSwordSettings(
getDataFolder().toPath(), greatSwordConfiguration);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordInitial);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
(size, title) -> getServer().createInventory(null, size, title),
@@ -528,11 +540,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions, nextTick);
sessions.attachNutritionPolicy(nutritionPolicy);
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
getDataFolder().toPath(), recoveryConfiguration);
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
Clock.systemUTC(), nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING,
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
@@ -545,7 +558,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
getDataFolder().toPath(), healthStealInitial.healing().toString());
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
combatFeedback);
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
@@ -618,7 +631,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
rareMagicCores=new games.dmg.fantasystick.shop.RareMagicCoreService(rareInventory,characters,sessions,nextTick,failure->getLogger().log(Level.WARNING,"Rare Magic Core use failed",failure));
rarePotions.install();rareReskills.install();rareMagicCores.install();books.attachRareInventory(rareInventory,rarePotions,rareReskills,rareMagicCores);
@@ -660,6 +673,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachFireball(fireballSettings, fireball);
admin.attachGreatSword(greatSwordSettings, greatSword);
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachGuardianBond(guardianBondSettings, guardianBond);
@@ -727,10 +741,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
particlesCommand.attachEvents(preferenceEvents);
feedbackCommand.attachEvents(preferenceEvents);
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(frostShard::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(longbow::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(localBasicAttack::ownsAcceptedDamage);
localBasicAttack.attachEvents(new games.dmg.fantasystick.events.BasicAttackEventBridge(
eventLog::emit, Clock.systemUTC()));
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
@@ -738,6 +758,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
abilityEngine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(eventLog::emit, Clock.systemUTC()));
abilityEngine.enableCastEventsFor("fireball");
abilityEngine.enableCastEventsFor("great_sword");
abilityEngine.enableCastEventsFor("frost_shard");
abilityEngine.enableCastEventsFor("longbow");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
@@ -757,6 +780,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
nutritionAuditEvents = new games.dmg.fantasystick.events.NutritionEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Nutrition event intent failed", failure));
sessions.attachNutritionEvents(nutritionAuditEvents);
nutritionPolicy.attachEvents(nutritionAuditEvents);
switchAuditEvents = new games.dmg.fantasystick.events.SwitchDelayEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
@@ -787,7 +815,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -868,6 +896,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
recipeDiscovery.attachEvents(recipeEvents);
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
var replayNutritionEvents = nutritionAuditEvents;
nutritionAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
if (replayNutritionEvents != null) replayNutritionEvents.replay();
});
var replaySwitchEvents = switchAuditEvents;
switchAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
@@ -926,6 +959,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
@@ -971,6 +1005,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
var closingFireballSettings = fireballSettings; fireballSettings = null;
var closingGreatSwordSettings = greatSwordSettings; greatSwordSettings = null;
var closingFrostShard = frostShard; frostShard = null;
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
@@ -1045,6 +1080,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
if (closingGreatSwordSettings != null) cleanup("Great Sword configuration", closingGreatSwordSettings::close);
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
@@ -1131,6 +1167,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingSettings.drained().toCompletableFuture(),
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFireballSettings.drained().toCompletableFuture(),
closingGreatSwordSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingGreatSwordSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -1234,6 +1272,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
if (closingRecipeDiscovery != null) cleanup("recipe discovery event intents", closingRecipeDiscovery::close);
if (closingNutritionAuditEvents != null) cleanup("nutrition event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
nutritionAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Nutrition owner drain failed", failure);
closingNutritionAuditEvents.close();
return null;
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
});
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
@@ -1548,15 +1595,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid);
}
private java.util.Map<String, String> greatSwordConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (var key : games.dmg.fantasystick.persistence.GreatSwordSettings.keys())
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(key)));
games.dmg.fantasystick.persistence.GreatSwordSettings.parse(values);
return java.util.Map.copyOf(values);
}
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
@@ -69,6 +69,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
if (castEvents != null) castEvents.resolved(audit, outcome, reason);
}
private void refuseCast(ControlInput input, CharacterRecord record, AbilityDefinition definition, String reason) {
if (castEvents == null || record == null
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow"))
|| !eventOwnedAbilities.contains(definition.id())) return;
resolve(castEvents.begin(input, record, definition), "refused", reason);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
@@ -170,11 +177,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null || !record.learned().contains(definition.id())
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
refuseCast(input, record, definition, "not_learned_or_assigned");
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
}
Instant now = clock.instant();
Instant existing = record.cooldowns().get(definition.id());
if (existing != null && now.isBefore(existing)) {
refuseCast(input, record, definition, "cooldown_active");
input.player().sendMessage("" + definition.name() + " is ready in "
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
}
@@ -185,11 +194,15 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
}
if (!accepted) {
refuseCast(input, record, definition, "target_ineligible");
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
}
var key = new Key(input.session().character(), definition.id());
Object token = new Object();
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
if (pending.putIfAbsent(key, token) != null) {
refuseCast(input, record, definition, "save_in_progress");
input.player().sendMessage("That cast is still being saved."); return;
}
long generation = generations.getOrDefault(key, 0L);
Instant readyAt;
try { readyAt = now.plus(definition.cooldown()); }
@@ -446,9 +459,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
}
@Override public void effectEnded(UUID application, String reason) {
Objects.requireNonNull(application); Objects.requireNonNull(reason);
effectEnded(application, reason, Map.of());
}
@Override public void effectEnded(UUID application, String reason, Map<String, Object> evidence) {
Objects.requireNonNull(application); Objects.requireNonNull(reason); Objects.requireNonNull(evidence);
var effectId = observedEffects.remove(application);
if (effectId != null && castEvents != null) castEvents.effectEnded(audit, application, effectId, reason);
if (effectId != null && castEvents != null)
castEvents.effectEnded(audit, application, effectId, reason, evidence);
}
@Override public void resolve(String result) { resolve(result, Map.of()); }
@Override public void resolve(String result, Map<String, Object> evidence) {
@@ -46,6 +46,10 @@ public interface AbilityExecution extends AutoCloseable {
double base, double bonus, double finalDamage, java.math.BigDecimal percent);
/** End an owned observed effect once; ordinary close ends any remaining observed effects. */
void effectEnded(UUID applicationId, String reason);
/** End an observed effect with actual native cleanup/restoration evidence. */
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
effectEnded(applicationId, reason);
}
/** An owner may specify a truthful terminal outcome before finishing. */
void resolve(String outcome);
void resolve(String outcome, Map<String, Object> evidence);
@@ -6,6 +6,7 @@ import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -58,7 +59,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
double requested;
boolean accepted;
boolean nativeCancelled;
boolean finished;
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
@@ -74,13 +77,33 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
private final LivingEntity target;
private final PotionEffect previous;
private final PotionEffect applied;
private final AbilityExecution execution;
private final long started = System.nanoTime();
private UUID application;
private String endReason = "interrupted";
private boolean owned;
private boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
this.execution = execution;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
boolean apply() {
owned = target.addPotionEffect(applied);
if (owned) try {
var evidence = new LinkedHashMap<String, Object>();
evidence.put("amplifier", applied.getAmplifier());
evidence.put("duration_ticks", applied.getDuration());
evidence.put("deadline", java.time.Instant.now().plusMillis(applied.getDuration() * 50L).toString());
evidence.put("prior_layer", previous == null ? "none" : "replaced");
if (previous != null) {
evidence.put("prior_amplifier", previous.getAmplifier());
evidence.put("prior_duration_ticks", previous.getDuration());
}
application = execution.effectApplied("frost_shard_slowness", target, evidence);
} catch (RuntimeException ignored) { /* Native effect authority is independent of auditing. */ }
return owned;
}
void expire() { endReason = "expired"; close(); }
@Override public void close() {
if (closed) return;
closed = true;
@@ -88,13 +111,24 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
if (current == null) {
end(endReason.equals("expired") ? "expired" : "native_layer_missing", "none"); return;
}
if (current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2 || current.getDuration() < expected - 2) {
end("superseded", "preserved_newer"); return;
}
target.removePotionEffect(PotionEffectType.SLOWNESS);
String restored = "none";
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
if (remaining > 0)
restored = target.addPotionEffect(previous.withDuration(remaining)) ? "prior" : "refused";
}
end(endReason, restored);
}
private void end(String reason, String restored) {
if (application != null) execution.effectEnded(application, reason, Map.of("restored_layer", restored));
}
}
@@ -130,6 +164,18 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
private static Settings captured(AbilityDefinition definition) {
var values = definition.parameters();
return new Settings(values.get("base-damage").toBigIntegerExact(), definition.cooldown(),
values.get("range").doubleValue(), duration(values.get("lifetime")),
values.get("projectile-speed").doubleValue(), values.get("slowness-amplifier").intValueExact(),
duration(values.get("slowness-duration")));
}
private static Duration duration(BigDecimal seconds) {
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(4), intelligence);
}
@@ -146,7 +192,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = settings;
var current = captured(execution.definition());
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
@@ -161,12 +207,15 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
execution.later(current.lifetime(), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
Runnable expire = () -> {
if (projectiles.containsKey(projectile.getUniqueId())) {
try { execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Audit capture cannot retain an expired Arrow. */ }
execution.finish();
}
};
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
execution.later(current.lifetime(), expire);
particles.world(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@@ -183,25 +232,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
if (state == null) return;
boolean retainExecution = false;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
boolean currentSession = !closed && state.execution.stillCurrent();
if (!currentSession || event.isCancelled()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
Map.of("projectile_id", state.projectile.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Audit capture cannot retain an invalid hit. */ }
return;
}
var current = state.settings;
BigInteger amount = damage(current.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target; damaging = state;
state.target = target; state.requested = nativeDamage; damaging = state;
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
"target_entity_id", target.getUniqueId().toString(),
"impact_location", locationData(state.projectile.getLocation()))); }
catch (RuntimeException ignored) { /* Capturing evidence cannot prevent native Slowness. */ }
if (state.accepted && state.execution.stillCurrent()) {
int ticks = Math.toIntExact(Math.max(1,
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
current.slownessAmplifier(), false, true, true));
current.slownessAmplifier(), false, true, true), state.execution);
if (lease.apply()) {
state.execution.own(lease);
state.execution.later(current.slownessDuration(), state.execution::finish);
state.execution.later(current.slownessDuration(), () -> {
lease.expire(); state.execution.finish();
});
retainExecution = true;
}
}
@@ -217,18 +279,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
if (state == null || state.accepted || event.getEntity() != state.target) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
if (event.isCancelled()) { state.nativeCancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
state.accepted = true;
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.projectile,
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
"impact_location", locationData(state.projectile.getLocation()))); }
catch (RuntimeException ignored) { /* An audit failure cannot remove an accepted combat contribution. */ }
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target) return false;
var direct = event.getDamageSource().getDirectEntity();
return direct == state.projectile || event instanceof EntityDamageByEntityEvent by
&& by.getDamager() == state.projectile;
}
private static Map<String, Object> locationData(Location location) {
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
}
private static String level(int amplifier) {
return switch (amplifier + 1) {
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
@@ -33,18 +33,25 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
private static final class Attempt {
final AbilityExecution execution;
final LivingEntity target;
final double requested;
final BigDecimal full;
final float charge;
boolean reported;
Attempt(AbilityExecution execution, LivingEntity target) {
boolean cancelled;
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal full, float charge) {
this.execution = execution; this.target = target;
this.requested = requested; this.full = full; this.charge = charge;
}
}
private final Settings settings;
private Settings settings;
private Attempt damaging;
private boolean closed;
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
List.of("Charge a contained melee strike.",
@@ -81,8 +88,11 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
execution.input().attackStrength());
BigInteger strength = execution.character().stats().get(Stat.STR);
BigInteger baseDamage = execution.definition().parameters().get("base-damage").toBigIntegerExact();
float charge = execution.input().attackStrength();
BigDecimal full = damage(baseDamage, strength, 1.0f);
BigDecimal amount = damage(baseDamage, strength, charge);
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : amount.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
@@ -91,23 +101,40 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
var player = execution.input().player();
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
.withDirectEntity(player).build();
var attempt = new Attempt(execution, target);
var attempt = new Attempt(execution, target, nativeDamage, full, charge);
damaging = attempt;
try { execution.damage(target, nativeDamage, source); }
finally { if (damaging == attempt) damaging = null; }
try {
execution.damage(target, nativeDamage, source);
execution.resolve(attempt.reported ? "hit" : attempt.cancelled ? "cancelled" : "miss",
Map.of("requested_amount", BigDecimal.valueOf(nativeDamage).toPlainString(),
"full_amount", full.toPlainString(), "charge", Float.toString(charge)));
} finally { if (damaging == attempt) damaging = null; }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
/** The generic incoming-player adapter must yield to this contained strike's effect owner. */
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
}
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target) return false;
var source = event.getDamageSource();
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
}
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.reported || event.isCancelled()
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|| (event.getDamageSource().getCausingEntity() != null
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|| (event.getDamageSource().getDirectEntity() != null
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
if (attempt == null || attempt.reported || !matches(attempt, event)) return;
if (event.isCancelled()) { attempt.cancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.reported = true;
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), Map.of("charge", Float.toString(attempt.charge),
"full_amount", attempt.full.toPlainString(),
"scaled_requested_amount", BigDecimal.valueOf(attempt.requested).toPlainString()));
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@@ -58,7 +58,9 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
final Settings settings;
final BigDecimal damage;
LivingEntity target;
double requested;
boolean accepted;
boolean nativeCancelled;
boolean finished;
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
@@ -141,12 +143,15 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
});
execution.later(shot.lifetime(), () -> {
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
});
Runnable expire = () -> {
if (arrows.containsKey(arrow.getUniqueId())) {
try { execution.resolve("expired", Map.of("arrow_id", arrow.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Auditing cannot retain expired native arrows. */ }
execution.finish();
}
};
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
execution.later(shot.lifetime(), expire);
}
@EventHandler(priority = EventPriority.HIGHEST)
@@ -161,18 +166,32 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
var state = arrows.remove(event.getEntity().getUniqueId());
if (state == null) return;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target)
boolean currentSession = !closed && state.execution.stillCurrent();
if (!currentSession || event.isCancelled() || !(event.getHitEntity() instanceof LivingEntity target)
|| !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
return;
}
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : state.damage.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
try { state.execution.resolve("miss", Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
return;
}
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
state.target = target; damaging = state;
state.target = target; state.requested = nativeDamage; damaging = state;
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
"target_entity_id", target.getUniqueId().toString(),
"impact_location", locationData(state.arrow.getLocation()))); }
catch (RuntimeException ignored) { /* Audit capture cannot change native damage. */ }
} finally {
state.close(); state.execution.finish();
}
@@ -184,18 +203,38 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
if (state == null || state.accepted || event.getEntity() != state.target) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.arrow)) return;
if (event.isCancelled()) { state.nativeCancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
state.accepted = true;
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.arrow,
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
"impact_location", locationData(state.arrow.getLocation()))); }
catch (RuntimeException ignored) { /* Audit capture cannot change an accepted contribution. */ }
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target) return false;
var direct = event.getDamageSource().getDirectEntity();
return direct == state.arrow || event instanceof EntityDamageByEntityEvent by
&& by.getDamager() == state.arrow;
}
private static Map<String, Object> locationData(Location location) {
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
@@ -36,7 +36,41 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
public void open(Player player){open(player,0);}
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void fish(PlayerFishEvent event){if(closed)return;var player=event.getPlayer();var item=player.getInventory().getItemInMainHand();if(!nativeUse(player,item))return;var context=context(player);var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);if(context==null||quest==null||!eligible(context,quest))return;switch(event.getState()){case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}case CAUGHT_FISH->{var claim=hooks.remove(player.getUniqueId());if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())||!(event.getCaught() instanceof Item caught)||!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();player.sendMessage("Quest catch refused: the owned hook and fishing area must remain valid.");return;}event.setCancelled(true);event.setExpToDrop(0);caught.remove();BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});}default->{if(event.getState()!=PlayerFishEvent.State.BITE)hooks.remove(player.getUniqueId());}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
public void fish(PlayerFishEvent event){
if(closed)return;
var player=event.getPlayer();
var item=player.getInventory().getItemInMainHand();
if(!nativeUse(player,item))return;
var context=context(player);
var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);
if(context==null||quest==null||!eligible(context,quest))return;
switch(event.getState()){
case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}
case LURED,BITE->{} // Approach and bite are not reels; retain the original owned hook.
case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}
case CAUGHT_FISH->{
var claim=hooks.get(player.getUniqueId());
if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())){
refuseCatch(event,"Quest catch refused: the cast is no longer valid; cast again.");return;
}
hooks.remove(player.getUniqueId(),claim); // A late callback for another hook must not consume the current cast.
if(!(event.getCaught() instanceof Item caught)){
refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
}
if(!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
}
event.setCancelled(true);event.setExpToDrop(0);caught.remove();event.getHook().remove();
BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();
UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);
fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});
}
case IN_GROUND,FAILED_ATTEMPT,REEL_IN->hooks.remove(player.getUniqueId());
}
}
private void refuseCatch(PlayerFishEvent event,String reason){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();event.getPlayer().sendMessage(reason);}
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());}
@EventHandler(priority=EventPriority.MONITOR)public void changed(org.bukkit.event.player.PlayerItemHeldEvent event){cleanup(event.getPlayer());}
@EventHandler(priority=EventPriority.MONITOR)public void opened(org.bukkit.event.inventory.InventoryOpenEvent event){if(event.getPlayer() instanceof Player player&&hooks.containsKey(player.getUniqueId()))cleanup(player);}
@@ -28,6 +28,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
private final CombatFeedback feedback;
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private games.dmg.fantasystick.events.BasicAttackEventBridge events;
private Pending pending;
private boolean closed;
@@ -36,9 +37,18 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
final LivingEntity target;
final Instant attackedAt;
final boolean knockback;
final games.dmg.fantasystick.events.BasicAttackEventBridge.Action action;
final double requested;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
boolean nativeSeen;
boolean nativePriorCancelled;
boolean nativeCancelled;
Double nativeFinal;
EntityDamageByEntityEvent acceptedEvent;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback,
double requested, games.dmg.fantasystick.events.BasicAttackEventBridge.Action action) {
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
this.requested = requested; this.action = action;
}
}
@@ -62,26 +72,38 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
this.trainingDamage = Objects.requireNonNull(trainingDamage);
}
public void attachEvents(games.dmg.fantasystick.events.BasicAttackEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Basic attack events already attached");
events = Objects.requireNonNull(bridge);
}
@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;
if (input.kind() != ControlInput.Kind.BASIC_ATTACK || input.gesture() != ControlInput.Gesture.ATTACK) return;
Instant now = clock.instant();
Instant ready = readyAt.get(input.session().character());
if (ready != null && now.isBefore(ready)) return;
if (closed || pending != null || !(input.target() instanceof LivingEntity) || !input.stillCurrent()) {
refuse(input, null, now, ready, closed ? "closed" : pending != null ? "attack_in_progress"
: !(input.target() instanceof LivingEntity) ? "ineligible_target" : "stale_input");
return;
}
var target = (LivingEntity) input.target();
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null) { refuse(input, null, now, ready, "missing_character"); return; }
if (ready != null && now.isBefore(ready)) { refuse(input, record, now, ready, "cadence_not_ready"); return; }
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var action = events == null ? null : events.observed(input, record, target, now, ready, nativeDamage);
var attempt = new Pending(input, target, now,
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID), nativeDamage, action);
pending = attempt;
boolean completed = false;
try {
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
measured.ifPresent(amount -> {
attempt.accepted = true;
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
});
completed = true;
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) {
@@ -91,9 +113,24 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
}
}
if (events != null) events.resolved(action, attempt.accepted ? "accepted" : !completed ? "failed"
: attempt.nativeCancelled ? "cancelled" : "refused",
attempt.accepted ? attempt.nativeSeen ? "native_positive_hit" : "training_measurement"
: !completed ? "native_damage_failed" : attempt.nativePriorCancelled ? "prior_cancelled"
: attempt.nativeCancelled ? "native_cancelled" : attempt.nativeSeen
? "zero_or_invalid_damage" : "native_hit_unverified",
attempt.nativePriorCancelled, attempt.nativeCancelled, attempt.nativeFinal,
attempt.accepted ? readyAt.get(input.session().character()) : null);
}
}
private void refuse(ControlInput input, games.dmg.fantasystick.persistence.CharacterRecord record,
Instant now, Instant ready, String reason) {
if (events == null) return;
var action = events.observed(input, record, input.target(), now, ready, null);
events.resolved(action, "refused", reason, false, false, null, null);
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
@@ -101,13 +138,35 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.LOWEST)
public void observedNativeDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (attempt != null && event.getDamager() == attempt.input.player() && event.getEntity() == attempt.target)
attempt.nativePriorCancelled = event.isCancelled();
}
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
var attempt = pending;
if (closed || attempt == null || !(event instanceof EntityDamageByEntityEvent by)) return false;
if (attempt.accepted) return attempt.acceptedEvent == event;
return by.getDamager() == attempt.input.player() && by.getEntity() == attempt.target
&& !event.isCancelled() && Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0
&& attempt.input.stillCurrent() && sessions.current(attempt.input.session());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
if (closed || attempt == null || attempt.accepted || event.getDamager() != attempt.input.player()
|| event.getEntity() != attempt.target) return;
attempt.nativeSeen = true;
attempt.nativeCancelled = event.isCancelled();
if (event.isCancelled() || event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
attempt.acceptedEvent = event;
attempt.nativeFinal = event.getFinalDamage();
if (events != null) events.acceptedDamage(attempt.action, event, attempt.attackedAt.plus(CADENCE));
if (feedback != null) feedback.report("Basic Attack", attempt.input.player(), AbilityReport.Type.DAMAGE,
event.getEntity(), java.math.BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@@ -18,7 +18,23 @@ import org.bukkit.entity.Entity;
/** Shared cast and explicit effect-observation boundary; never infers effects from feedback. */
public final class AbilityCastEventBridge {
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt) { }
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
float attackCharge, int heldTicks, UUID executionId) {
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
float attackCharge, int heldTicks) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
attackCharge, heldTicks, UUID.randomUUID());
}
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, attackCharge, 0);
}
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, 1.0f);
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
@@ -39,7 +55,7 @@ public final class AbilityCastEventBridge {
definition.id(), target == null ? null : target.getUniqueId(),
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
clock.instant());
clock.instant(), input.attackStrength(), input.heldTicks());
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
return cast;
} catch (RuntimeException ignored) {
@@ -59,6 +75,12 @@ public final class AbilityCastEventBridge {
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
data.put("captured_settings", settings);
data.put("settings_scope", "definition_at_request");
if (cast.abilityId().equals("great_sword")) data.put("captured_charge", Float.toString(cast.attackCharge()));
if (cast.abilityId().equals("longbow")) {
data.put("draw_ticks", cast.heldTicks());
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
definition.parameters().get("full-draw-ticks").intValueExact()).stripTrailingZeros().toPlainString());
}
for (Stat stat : definition.requirements().keySet())
data.put("captured_" + stat.name().toLowerCase(java.util.Locale.ROOT), saved.stats().get(stat).toString());
emit(cast, "ability.cast", "accepted", data);
@@ -223,9 +245,19 @@ public final class AbilityCastEventBridge {
}
public void effectEnded(Cast cast, UUID application, String effectId, String reason) {
effectEnded(cast, application, effectId, reason, Map.of());
}
public void effectEnded(Cast cast, UUID application, String effectId, String reason,
Map<String, Object> evidence) {
if (cast == null) return;
emit(cast, "effect.ended", "ended", Map.of("application_id", application.toString(),
"effect_id", effectId, "reason", reason), application);
try {
var details = new LinkedHashMap<String, Object>(evidence);
details.put("application_id", application.toString());
details.put("effect_id", effectId);
details.put("reason", reason);
emit(cast, "effect.ended", "ended", details, application);
} catch (RuntimeException ignored) { /* Native cleanup is not conditional on audit evidence. */ }
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra) {
@@ -238,6 +270,8 @@ public final class AbilityCastEventBridge {
data.put("action_kind", "ability_cast");
data.put("ability_id", cast.abilityId());
data.put("outcome", outcome);
if (cast.abilityId().equals("frost_shard") || cast.abilityId().equals("longbow"))
data.put("execution_id", cast.executionId().toString());
if (cast.targetId() != null) {
data.put("target_entity_id", cast.targetId().toString());
data.put("target_type", cast.targetType());
@@ -0,0 +1,117 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Immutable server-thread snapshots for the owned bound-stick input and its native hit. */
public final class BasicAttackEventBridge {
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public BasicAttackEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Action observed(ControlInput input, CharacterRecord record, Entity target, Instant at,
Instant ready, Double requested) {
try {
var player = input.player();
var context = new EventInput.Context(player.getUniqueId(), player.getName(),
record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : input.session().session(), location(player.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "basic_attack");
data.put("entry_point", "bound_slot_2_attack");
data.put("slot_index", input.slot());
data.put("control_kind", input.kind().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_player_id", player.getUniqueId().toString());
data.put("source_character_id", input.session().character().toString());
data.put("source_session_id", input.session().session().toString());
if (record != null) data.put("strength", record.stats().get(games.dmg.fantasystick.progression.Stat.STR).toString());
else data.put("strength_status", "unavailable");
data.put("ready_deadline", (ready == null ? at : ready).toString());
if (ready == null) data.put("ready_deadline_status", "ready_at_observation_no_prior_hit");
if (requested != null) data.put("requested_amount", Double.toString(requested));
else data.put("requested_amount_status", "not_computed");
data.put("prior_cancelled", false);
if (target != null && target.getUniqueId() != null) data.put("target_entity_id", target.getUniqueId().toString());
if (target instanceof org.bukkit.entity.Player) data.put("target_entity_type", "player");
else if (target != null && target.getType() != null)
data.put("target_entity_type", target.getType().name().toLowerCase(java.util.Locale.ROOT));
var sourceLocation = location(player.getLocation());
var targetLocation = target == null ? null : location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(UUID.randomUUID(), at, context, Map.copyOf(data));
var values = new LinkedHashMap<String, Object>(data);
values.put("outcome", "observed");
emit(action, "action.observed", "observed", at, values);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason, boolean nativePriorCancelled,
boolean finalCancelled, Double finalAmount, Instant nextReady) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", outcome);
values.put("reason", reason);
values.put("native_prior_cancelled", nativePriorCancelled);
values.put("final_cancelled", finalCancelled);
if (finalAmount != null) values.put("final_amount", Double.toString(finalAmount));
else values.put("final_amount_status", "unavailable");
if (nextReady != null) values.put("next_ready_deadline", nextReady.toString());
emit(action, "action.resolved", "resolved", clock.instant(), values);
}
public void acceptedDamage(Action action, EntityDamageByEntityEvent event, Instant nextReady) {
if (action == null) return;
try {
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", "accepted");
values.put("reason", "native_positive_hit");
values.put("final_cancelled", false);
values.put("final_amount", Double.toString(event.getFinalDamage()));
values.put("next_ready_deadline", nextReady.toString());
var target = event.getEntity();
if (target instanceof org.bukkit.entity.Player player) values.put("target_player_id", player.getUniqueId().toString());
var captured = location(target.getLocation());
if (captured != null) values.put("target_location", coordinates(captured));
else values.put("target_location_status", "unavailable");
emit(action, "combat.damage", "damage", clock.instant(), values);
} catch (RuntimeException ignored) { }
}
private EventInput.Location location(Location nativeLocation) {
return nativeLocation == null || nativeLocation.getWorld() == null ? null
: new EventInput.Location(nativeLocation.getWorld().getUID(), nativeLocation.getWorld().getName(),
nativeLocation.getX(), nativeLocation.getY(), nativeLocation.getZ(), clock.instant(), "exact");
}
private Map<String, Object> coordinates(EventInput.Location value) {
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(), "x", value.x(),
"y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString());
}
private void emit(Action action, String type, String stage, Instant time, Map<String, Object> values) {
try {
sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:basic-attack:" + action.id() + ':'
+ stage).getBytes(StandardCharsets.UTF_8)), action.id(), time, type, action.actor(), values));
} catch (RuntimeException ignored) { }
}
}
@@ -0,0 +1,159 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.OrdinaryVitals;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Captures nutrition at the native boundary; success follows an acknowledged session receipt. */
public final class NutritionEventBridge implements AutoCloseable {
public record Capture(EventInput.Context context, UUID session, UUID character, Instant time,
int food, float saturation, float exhaustion, String reason) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
public NutritionEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "nutrition-event-intents",
Set.of("nutrition.suspended", "nutrition.restored"), sink, failures);
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
public Capture capture(Player player, UUID session, UUID character, String name, String reason) {
try {
var now = clock.instant();
var where = location(player.getLocation(), now);
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
name == null ? null : character, name, name == null ? null : session, where),
session, character, now, player.getFoodLevel(), player.getSaturation(), player.getExhaustion(), reason);
} catch (RuntimeException ignored) { return null; }
}
public void suspended(Player player, UUID session, CharacterRecord character, OrdinaryVitals ordinary,
BigInteger receiptRevision) {
if (ordinary == null || receiptRevision == null) return;
var captured = capture(player, session, character.id(), character.name(), "entry");
if (captured == null) return;
var data = fields(captured, receiptRevision);
data.put("ordinary_food", ordinary.food());
data.put("ordinary_saturation", (double) ordinary.saturation());
data.put("ordinary_exhaustion", (double) ordinary.exhaustion());
data.put("fantasy_food", captured.food());
data.put("fantasy_saturation", (double) captured.saturation());
data.put("fantasy_exhaustion", (double) captured.exhaustion());
data.put("outcome", "acknowledged");
durable(captured, "nutrition.suspended", "suspended", data);
}
public void restored(Capture before, Capture after, OrdinaryVitals ordinary, BigInteger receiptRevision) {
if (before == null || after == null || ordinary == null || receiptRevision == null
|| !before.session().equals(after.session())) return;
var data = fields(before, receiptRevision);
data.put("before_food", before.food());
data.put("before_saturation", (double) before.saturation());
data.put("before_exhaustion", (double) before.exhaustion());
data.put("restored_food", after.food());
data.put("restored_saturation", (double) after.saturation());
data.put("restored_exhaustion", (double) after.exhaustion());
data.put("outcome", "acknowledged");
durable(after, "nutrition.restored", "restored", data);
}
public void satiatedRefused(Player player, UUID session, UUID character, String name, double amount) {
var captured = capture(player, session, character, name, "satiated_regeneration");
if (captured == null) return;
var action = UUID.randomUUID();
for (var stage : new String[]{"observed", "resolved"}) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "satiated_regeneration");
data.put("session_id", session.toString());
data.put("character_id", character.toString());
data.put("requested_amount", amount);
data.put("regain_source", "SATIATED");
data.put("outcome", stage.equals("observed") ? "observed" : "cancelled");
if (stage.equals("resolved")) data.put("reason", "nutrition_policy");
try { sink.apply(new EventInput(id(action, stage), action, captured.time(),
"action." + stage, captured.context(), data)); }
catch (RuntimeException ignored) { /* Logging never changes a native cancellation. */ }
}
}
public void restorationRetained(Capture captured, String reason) {
if (captured == null) return;
var action = UUID.randomUUID();
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "nutrition_restore");
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("outcome", "retained");
data.put("reason", reason);
try { sink.apply(new EventInput(id(action, "retained"), action, captured.time(),
"action.resolved", captured.context(), data)); }
catch (RuntimeException ignored) { }
}
/** Native event alone is not success; a later observed health change proves an applied effect. */
public void nativeHealed(Capture captured, double before, double after, String source) {
if (captured == null || !Double.isFinite(before) || !Double.isFinite(after) || after <= before) return;
var action = UUID.randomUUID();
var data = new LinkedHashMap<String, Object>();
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("regain_source", source);
data.put("health_before", before);
data.put("health_after", after);
data.put("actual_amount", after - before);
data.put("persistence_result", "pending_session_health_save");
data.put("publication_result", "native_health_observed");
data.put("outcome", "applied");
try { sink.apply(new EventInput(id(action, "healed"), action, captured.time(),
"combat.healed", captured.context(), data)); }
catch (RuntimeException ignored) { /* An event never authorizes a health change. */ }
}
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
var data = new LinkedHashMap<String, Object>();
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("receipt_id", captured.session().toString());
data.put("receipt_revision", revision.toString());
data.put("reason", captured.reason());
return data;
}
private void durable(Capture captured, String type, String phase, LinkedHashMap<String, Object> data) {
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.time(),
type, captured.context(), data)); }
catch (RuntimeException ignored) { /* Audit failure cannot change nutrition custody. */ }
}
private EventInput.Location location(Location value, Instant now) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), now, "exact");
}
private static UUID id(UUID session, String phase) {
return UUID.nameUUIDFromBytes(("fantasystick:nutrition:" + session + ':' + phase)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -76,11 +76,12 @@ public final class FrostShardSettings implements AutoCloseable {
public FrostShard.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -0,0 +1,159 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.GreatSword;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized Great Sword configuration; the caller publishes acknowledged changes on the server thread. */
public final class GreatSwordSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "great-sword-base-damage";
public static final String RECOVERY = "great-sword-recovery-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "7", RECOVERY, "0.75");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, GreatSword.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-great-sword-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public GreatSwordSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
GreatSwordSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public GreatSword.Settings settings() { return parse(effectiveValues); }
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence(); return true;
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Great Sword configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static GreatSword.Settings parse(Map<String, String> values) {
try {
var seconds = new java.math.BigDecimal(values.get(RECOVERY));
Duration recovery = Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new GreatSword.Settings(new java.math.BigInteger(values.get(BASE_DAMAGE)), recovery);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Great Sword settings must use supported numeric values", invalid);
}
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS))
throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -77,11 +77,12 @@ public final class LongbowSettings implements AutoCloseable {
public Longbow.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -27,6 +27,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.events.AbilityConfigEventBridge abilityConfigEvents;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
@@ -426,6 +428,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
partyRegistrars=Objects.requireNonNull(directory); partyRegistrarController=Objects.requireNonNull(controller);
}
public void attachGreatSword(games.dmg.fantasystick.persistence.GreatSwordSettings settings,
games.dmg.fantasystick.ability.GreatSword effect) {
if (closed || greatSwordSettings != null) throw new IllegalStateException("Great Sword already attached or command closed");
greatSwordSettings = Objects.requireNonNull(settings); greatSword = Objects.requireNonNull(effect);
}
public void attachFireball(games.dmg.fantasystick.persistence.FireballSettings settings,
games.dmg.fantasystick.ability.Fireball effect) {
if (closed || fireballSettings != null) throw new IllegalStateException("Fireball already attached or command closed");
@@ -1050,12 +1058,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (fireballSettings != null && games.dmg.fantasystick.persistence.FireballSettings.keys().contains(args[1])) {
fireballConfiguration(sender, args); return;
}
if (greatSwordSettings != null && games.dmg.fantasystick.persistence.GreatSwordSettings.keys().contains(args[1])) {
greatSwordConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = frostSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + frostSettings.value(key) + " (applies to future Frost Shard casts only)");
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_shard", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Frost Shard casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostShardSettings.Update> operation;
@@ -1069,21 +1083,56 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
try {
nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
frostShard.configure(saved.settings());
boolean applied = failure == null && frostSettings.publish(saved);
if (applied) {
frostShard.configure(frostSettings.settings());
abilities.replace(frostShard.definition());
frostSettings.publish(saved);
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "frost_shard", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
: "Frost Shard configuration save failed; the effective setting is unchanged.");
sender.sendMessage(failure != null
? "Frost Shard configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + frostSettings.value(key) + ".");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void greatSwordConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = greatSwordSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "great_sword", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Great Sword strikes only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GreatSwordSettings.Update> operation;
try { operation = greatSwordSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Great Sword setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
boolean applied = failure == null && greatSwordSettings.publish(saved);
if (applied) {
greatSword.configure(greatSwordSettings.settings()); abilities.replace(greatSword.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "great_sword", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure != null
? "Great Sword configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Great Sword strikes; existing deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + greatSwordSettings.value(key) + ".");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void fireballConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
@@ -1238,8 +1287,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void longbowConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = longbowSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "longbow", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Longbow shots only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
@@ -1253,16 +1305,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
try {
nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
longbow.configure(saved.settings());
boolean applied = failure == null && longbowSettings.publish(saved);
if (applied) {
longbow.configure(longbowSettings.settings());
abilities.replace(longbow.definition());
longbowSettings.publish(saved);
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "longbow", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
: "Longbow configuration save failed; the effective setting is unchanged.");
sender.sendMessage(failure != null
? "Longbow configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + longbowSettings.value(key) + ".");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
@@ -1845,6 +1899,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
keys.add(games.dmg.fantasystick.persistence.HealthStealSettings.KEY);
keys.addAll(games.dmg.fantasystick.persistence.FireballSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.GreatSwordSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
@@ -1909,6 +1964,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.FireballSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (greatSwordSettings != null && games.dmg.fantasystick.persistence.GreatSwordSettings.keys().contains(args[1])) {
String current = greatSwordSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
String current = frostSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
@@ -48,6 +48,8 @@ public final class EquipmentSessions implements AutoCloseable {
private java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> downedRewardAllocation=ignored->List.of();
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionEvents;
private NutritionPolicy nutritionPolicy;
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchEvents;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentEvents;
@@ -122,6 +124,10 @@ public final class EquipmentSessions implements AutoCloseable {
boolean exitRequested;
games.dmg.fantasystick.events.SessionEventBridge.ExitCapture exitEvent;
boolean exitEventWritten;
games.dmg.fantasystick.events.NutritionEventBridge.Capture nutritionBeforeRestore;
games.dmg.fantasystick.events.NutritionEventBridge.Capture nutritionAfterRestore;
boolean nutritionRestoreWritten;
boolean nutritionRetainedWritten;
games.dmg.fantasystick.events.SwitchDelayEventBridge.ExitContext switchExit;
boolean switchEventWritten;
String exitReason = "voluntary";
@@ -230,6 +236,8 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void attachDownedRewardAllocation(java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> allocation){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedRewardAllocation=Objects.requireNonNull(allocation);}
public synchronized void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(!owned.isEmpty()||downedEvents!=null)throw new IllegalStateException("Downed events already attached or sessions started");downedEvents=Objects.requireNonNull(events);}
public synchronized void attachSessionEvents(games.dmg.fantasystick.events.SessionEventBridge events){if(!owned.isEmpty()||sessionEvents!=null)throw new IllegalStateException("Session events already attached or sessions started");sessionEvents=Objects.requireNonNull(events);}
public synchronized void attachNutritionEvents(games.dmg.fantasystick.events.NutritionEventBridge events){if(!owned.isEmpty()||nutritionEvents!=null)throw new IllegalStateException("Nutrition events already attached or sessions started");nutritionEvents=Objects.requireNonNull(events);}
public synchronized void attachNutritionPolicy(NutritionPolicy policy){if(!owned.isEmpty()||nutritionPolicy!=null)throw new IllegalStateException("Nutrition policy already attached or sessions started");nutritionPolicy=Objects.requireNonNull(policy);}
public synchronized void attachSwitchEvents(games.dmg.fantasystick.events.SwitchDelayEventBridge events){if(!owned.isEmpty()||switchEvents!=null)throw new IllegalStateException("Switch events already attached or sessions started");switchEvents=Objects.requireNonNull(events);}
public synchronized void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events){if(!owned.isEmpty()||identityEvents!=null)throw new IllegalStateException("Identity events already attached or sessions started");identityEvents=Objects.requireNonNull(events);}
public synchronized void attachEquipmentEvents(games.dmg.fantasystick.events.EquipmentEventBridge events){if(!owned.isEmpty()||equipmentEvents!=null)throw new IllegalStateException("Equipment events already attached or sessions started");equipmentEvents=Objects.requireNonNull(events);}
@@ -748,6 +756,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
attempt.player.updateInventory();
if(active.get(attempt.owner)==attempt&&sessionEvents!=null)sessionEvents.entered(attempt.player,attempt.session,record,attempt.entryLocation,attempt.resumedDowned!=null,attempt.previousGameMode!=null);
if(active.get(attempt.owner)==attempt&&nutritionEvents!=null)nutritionEvents.suspended(
attempt.player, attempt.session, record, attempt.ordinaryVitals, attempt.packReceiptRevision);
if(active.get(attempt.owner)==attempt&&identityEvents!=null)identityEvents.entered(attempt.player,attempt.session,record);
} catch (RuntimeException error) {
fail(attempt, error); // Effects may have started: never roll back or replay them.
@@ -794,6 +804,8 @@ public final class EquipmentSessions implements AutoCloseable {
return EquipmentStorage.RestoreResult.NOT_FOUND;
}
cancelEntry(owner);
var current = active.get(owner);
if (current != null && nutritionPolicy != null) nutritionPolicy.flushNativeHealing(current.player);
var attempt = active.remove(owner);
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
attempt.exitReason = Objects.requireNonNull(reason);
@@ -1061,6 +1073,7 @@ public final class EquipmentSessions implements AutoCloseable {
if(sessionEvents!=null&&attempt.exitEvent==null&&!attempt.defeated&&attempt.downed==null&&!closed&&attempt.player.isOnline()&&attempt.record!=null)
attempt.exitEvent=sessionEvents.captureExit(attempt.player,attempt.session,attempt.record,attempt.entryLocation);
captureExitDelay(attempt);
if (nutritionPolicy != null) nutritionPolicy.flushNativeHealing(attempt.player);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
@@ -1201,6 +1214,7 @@ public final class EquipmentSessions implements AutoCloseable {
private void recoveryIncomplete(Attempt attempt, String reason) {
if (attempt.recoveryResolved && attempt.sessionRecoveryResolved) return;
nutritionRetained(attempt, reason);
if (equipmentEvents != null && attempt.recoveryAction != null && !attempt.recoveryResolved) {
attempt.recoveryResolved = true;
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
@@ -1229,11 +1243,34 @@ public final class EquipmentSessions implements AutoCloseable {
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
}
private void nutritionRetained(Attempt attempt, String reason) {
if (nutritionEvents == null || attempt.ordinaryVitals == null || attempt.nutritionRetainedWritten) return;
var captured = attempt.nutritionBeforeRestore == null
? nutritionEvents.capture(attempt.player, attempt.session, attempt.character,
attempt.record == null ? null : attempt.record.name(), attempt.exitReason)
: attempt.nutritionBeforeRestore;
if (captured == null) return;
attempt.nutritionRetainedWritten = true;
nutritionEvents.restorationRetained(captured, reason);
}
private boolean restoreVitals(Attempt attempt) {
if (vitals == null || attempt.ordinaryVitals == null) return true;
boolean applied = nutritionEvents != null && vitals.nutritionRestorationReady(
attempt.player, attempt.session, attempt.ordinaryVitals);
var before = nutritionEvents == null ? null : nutritionEvents.capture(attempt.player,
attempt.session, attempt.character, attempt.record == null ? null : attempt.record.name(), attempt.exitReason);
try {
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) return true;
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) {
if (applied && before != null) {
attempt.nutritionBeforeRestore = before;
attempt.nutritionAfterRestore = nutritionEvents.capture(attempt.player, attempt.session,
attempt.character, attempt.record == null ? null : attempt.record.name(), attempt.exitReason);
}
return true;
}
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
nutritionRetained(attempt, "native_restoration_unverified");
notice(attempt.owner, "VITALS_RECOVERY", "Health/nutrition restoration was refused or ambiguous; retained receipt requires inspection.");
return false;
}
@@ -1255,6 +1292,7 @@ public final class EquipmentSessions implements AutoCloseable {
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) {
nutritionRetained(attempt, "receipt_save_failed");
sessionReconciled(attempt,
attempt.nativeRestored ? "restored" : "pending",
attempt.nativeRestored ? "ambiguous" : "failed",
@@ -1287,14 +1325,21 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.switchEventWritten = true;
switchEvents.started(attempt.switchExit, state);
}
if (!attempt.nutritionRestoreWritten && nutritionEvents != null
&& attempt.nutritionAfterRestore != null) {
attempt.nutritionRestoreWritten = true;
nutritionEvents.restored(attempt.nutritionBeforeRestore, attempt.nutritionAfterRestore,
attempt.ordinaryVitals, state.revision());
}
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
attempt.exitEventWritten = true;
sessionEvents.exited(attempt.session,attempt.exitEvent);
}
if (attempt.defeated) queueTail(attempt);
release(attempt);
} else if (attempt.defeated) {
conversionUncertain(attempt,
} else {
nutritionRetained(attempt, !attempt.healthAcknowledged ? "health_unverified" : "receipt_incomplete");
if (attempt.defeated) conversionUncertain(attempt,
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
}
});
@@ -9,10 +9,39 @@ import org.bukkit.event.Listener;
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
public final class NutritionPolicy implements Listener, AutoCloseable {
private final EquipmentSessions sessions;
private final java.util.function.Consumer<Runnable> nextTick;
private final java.util.Set<org.bukkit.event.entity.EntityRegainHealthEvent> owned =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private interface PendingHealing extends Runnable { void flush(); }
private final java.util.Map<java.util.UUID, PendingHealing> pendingNativeHealing = new java.util.HashMap<>();
private final java.util.Set<java.util.UUID> ambiguousNativeHealing = new java.util.HashSet<>();
private final java.util.Set<org.bukkit.event.entity.EntityRegainHealthEvent> suppressed =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private games.dmg.fantasystick.events.NutritionEventBridge events;
private boolean closed;
public NutritionPolicy(EquipmentSessions sessions) {
this(sessions, ignored -> { });
}
public NutritionPolicy(EquipmentSessions sessions, java.util.function.Consumer<Runnable> nextTick) {
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
}
/** Explicit provenance for a plugin-owned heal that another feature owner audits. */
public void dispatchOwnedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event,
java.util.function.Consumer<? super org.bukkit.event.entity.EntityRegainHealthEvent> dispatcher) {
if (event.getEntity() instanceof Player player && pendingNativeHealing.containsKey(player.getUniqueId()))
ambiguousNativeHealing.add(player.getUniqueId());
owned.add(event);
try { dispatcher.accept(event); }
finally { owned.remove(event); }
}
public void attachEvents(games.dmg.fantasystick.events.NutritionEventBridge bridge) {
if (events != null) throw new IllegalStateException("Nutrition events already attached");
events = Objects.requireNonNull(bridge);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -36,7 +65,74 @@ public final class NutritionPolicy implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
&& active(event.getEntity())) event.setCancelled(true);
&& active(event.getEntity())) {
event.setCancelled(true);
suppressed.add(event);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
boolean blocked = suppressed.remove(event);
if (events == null || !(event.getEntity() instanceof Player player)) return;
var active = sessions.active(player.getUniqueId()).orElse(null);
if (active == null) return;
var record = sessions.currentRecord(active).orElse(null);
if (blocked) {
if (event.isCancelled()) events.satiatedRefused(player, active.session(), active.character(),
record == null ? null : record.name(), event.getAmount());
return;
}
if (event.isCancelled() || owned.contains(event) || !Double.isFinite(event.getAmount())
|| event.getAmount() <= 0 || record == null) return;
var owner = player.getUniqueId();
if (pendingNativeHealing.containsKey(owner)) {
ambiguousNativeHealing.add(owner);
return;
}
double before = player.getHealth();
double accepted = event.getAmount();
String source = event.getRegainReason().name();
var captured = events.capture(player, active.session(), active.character(), record.name(), "native_regain");
PendingHealing completion = new PendingHealing() {
@Override public void run() { finish(false); }
@Override public void flush() { finish(true); }
private void finish(boolean exiting) {
if (!pendingNativeHealing.remove(owner, this)) return;
boolean ambiguous = ambiguousNativeHealing.remove(owner);
if (closed || ambiguous || captured == null) return;
try {
boolean stillOwned = exiting
? sessions.active(owner).filter(active::equals).isPresent() && player.isOnline()
: sessions.current(active);
if (!stillOwned) return;
double after = player.getHealth();
double delta = after - before;
if (Double.isFinite(delta) && delta > 0 && delta <= accepted)
events.nativeHealed(captured, before, after, source);
} catch (RuntimeException ignored) {
// An audit observation cannot interrupt native healing or session exit.
}
}
};
pendingNativeHealing.put(owner, completion);
try { nextTick.accept(completion); }
catch (RuntimeException failure) { pendingNativeHealing.remove(owner, completion); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void concurrentDamage(org.bukkit.event.entity.EntityDamageEvent event) {
if (event.getEntity() instanceof Player player && pendingNativeHealing.containsKey(player.getUniqueId()))
ambiguousNativeHealing.add(player.getUniqueId());
}
/** Complete a proved native heal before the owning session is released in the same tick. */
public void flushNativeHealing(Player player) {
var pending = pendingNativeHealing.get(player.getUniqueId());
if (pending != null) {
try { pending.flush(); }
catch (RuntimeException ignored) { /* Never interrupt native exit for an audit. */ }
}
}
private boolean active(org.bukkit.entity.Entity entity) {
@@ -44,5 +140,5 @@ public final class NutritionPolicy implements Listener, AutoCloseable {
&& sessions.active(player.getUniqueId()).isPresent();
}
@Override public void close() { closed = true; }
@Override public void close() { closed = true; suppressed.clear(); owned.clear(); pendingNativeHealing.clear(); ambiguousNativeHealing.clear(); events = null; }
}
@@ -106,6 +106,13 @@ public final class SessionVitals implements AutoCloseable {
return BigDecimal.valueOf(Math.max(0, Math.min(player.getHealth(), mark.maximum())));
}
/** Only an applied marker permits a newly observed native nutrition restoration. */
public boolean nutritionRestorationReady(Player player, UUID session, OrdinaryVitals ordinary) {
var mark = read(player);
return ordinary != null && mark != null && mark.session().equals(session)
&& mark.ordinary().equals(ordinary) && mark.phase() == Phase.APPLIED;
}
public boolean restore(Player player, UUID session, OrdinaryVitals ordinary) {
if (ordinary == null) return true; // Legacy equipment-only receipt, not health restoration authority.
var mark = read(player);
@@ -0,0 +1,431 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered bound slot-2 input -> native damage -> local JSONL, not a direct bridge test. */
class BasicAttackEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void eventSnapshotReadsExportedSegmentsWithoutEnteringTransientPendingTree() throws Exception {
var events = data.resolve("events");
var day = events.resolve(java.time.LocalDate.now(java.time.ZoneOffset.UTC).toString());
var pending = events.resolve("pending");
Files.createDirectories(day); Files.createDirectories(pending);
Files.writeString(day.resolve("events-exported-0.jsonl"),
"{\"type\":\"games.dmg.minecraft.fantasystick.combat.damage\"}\n");
Files.writeString(pending.resolve("transient.jsonl"),
"{\"type\":\"games.dmg.minecraft.fantasystick.action.observed\"}\n");
var exported = records();
assertEquals(1, exported.size(), "Only durable exported date-segments are authoritative test evidence");
assertTrue(is(exported.getFirst(), "combat.damage"));
}
@Test void acceptedNativeBasicHitHasOneCorrelatedDamageRecordAndRealFinalAmount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
doAnswer(call -> {
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getCausingEntity()).thenReturn(source.player);
when(damageSource.getDirectEntity()).thenReturn(source.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource,
(Double) call.getArgument(0));
event.setDamage(0.75);
fixture.dispatcher.accept(event);
assertFalse(event.isCancelled());
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target.player, true);
fixture.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "Bound stick may not continue to vanilla damage");
for (int i = 0; i < 250 && records().stream().noneMatch(value -> is(value, "combat.damage")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
var entries = records();
var observed = actions(entries, "action.observed");
var resolved = actions(entries, "action.resolved");
var damage = entries.stream().filter(value -> is(value, "combat.damage")).toList();
assertEquals(1, observed.size()); assertEquals(1, resolved.size()); assertEquals(1, damage.size());
var start = observed.getFirst().getAsJsonObject("data");
var result = resolved.getFirst().getAsJsonObject("data");
var hit = damage.getFirst().getAsJsonObject("data");
assertEquals("basic_attack", start.get("action_kind").getAsString());
assertEquals(start.get("action_id").getAsString(), result.get("action_id").getAsString());
assertEquals(start.get("action_id").getAsString(), hit.get("action_id").getAsString());
assertEquals("accepted", result.get("outcome").getAsString());
assertEquals("1.0", hit.get("requested_amount").getAsString());
assertEquals("0.75", hit.get("final_amount").getAsString());
assertEquals(source.core.id.toString(), hit.get("source_player_id").getAsString());
assertEquals(target.core.id.toString(), hit.get("target_player_id").getAsString());
assertTrue(start.has("source_location")); assertTrue(hit.has("target_location"));
assertTrue(start.has("ready_deadline"));
}
}
@Test void partyVetoZeroDamageAndExactCadenceDoNotMintExtraSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
strength(source, 12);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
var basic = (games.dmg.fantasystick.combat.BasicAttack)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "basicAttack");
var clock = new BasicAttackNativeTest.MutableClock(java.time.Instant.now());
var field = games.dmg.fantasystick.combat.BasicAttack.class.getDeclaredField("clock");
field.setAccessible(true); field.set(basic, clock);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(source.core.id).orElseThrow();
var invitation = parties.invite(source.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var requests = new ArrayList<Double>();
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
doAnswer(call -> {
double requested = call.getArgument(0);
requests.add(requested);
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
var nativeHit = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, nativeSource, requested);
if (zero.get()) nativeHit.setDamage(0);
fixture.dispatcher.accept(nativeHit);
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
attack(fixture, source, target); // Party guard rejects this native attempt.
assertTrue(parties.leave(target.core.id, group.id()));
zero.set(true); attack(fixture, source, target); // Zero final amount, no deadline.
zero.set(false); attack(fixture, source, target); // STR 12: base 1 + modifier 1.
clock.advance(java.time.Duration.ofMillis(999)); attack(fixture, source, target);
assertEquals(3, requests.size(), "Early input cannot trigger a fourth native hit");
clock.advance(java.time.Duration.ofMillis(1)); attack(fixture, source, target);
assertEquals(List.of(2.0, 2.0, 2.0, 2.0), requests);
fixture.plugin.setEnabled(false);
var entries = records();
var observed = actions(entries, "action.observed");
var resolved = actions(entries, "action.resolved");
var damage = entries.stream().filter(value -> is(value, "combat.damage")).toList();
assertEquals(5, observed.size()); assertEquals(5, resolved.size()); assertEquals(2, damage.size());
assertEquals(List.of("cancelled", "refused", "accepted", "refused", "accepted"),
resolved.stream().map(value -> value.getAsJsonObject("data").get("outcome").getAsString()).toList());
assertEquals(List.of("native_cancelled", "zero_or_invalid_damage", "native_positive_hit",
"cadence_not_ready", "native_positive_hit"), resolved.stream()
.map(value -> value.getAsJsonObject("data").get("reason").getAsString()).toList());
assertTrue(damage.stream().allMatch(value -> value.getAsJsonObject("data").get("strength").getAsString().equals("12")
&& value.getAsJsonObject("data").get("requested_amount").getAsString().equals("2.0")));
assertEquals(2, damage.stream().map(value -> value.getAsJsonObject("data").get("action_id").getAsString())
.distinct().count());
}
}
@Test void optionalStickKnockbackDoesNotCreateAnotherDamageOutcome() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture);
strength(source, java.math.BigInteger.valueOf(12), List.of("stick_knockback", "", ""));
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doAnswer(call -> {
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), same(source.player));
fixture.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target, true));
verify(target).knockback(eq(1.0), anyDouble(), anyDouble());
fixture.plugin.setEnabled(false);
var entries = records();
assertEquals(1, entries.stream().filter(value -> is(value, "combat.damage")).count());
assertEquals(1, actions(entries, "action.resolved").size());
}
}
@Test void staleInputAfterExitAndRestartCannotReplayPreviousAcceptedHit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var old = sessions.active(source.core.id).orElseThrow();
var requests = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
requests.incrementAndGet();
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target.player, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, (Double) call.getArgument(0)));
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
attack(fixture, source, target);
var sheet = source.core.inventory.getItemInOffHand().clone();
var targetSheet = target.core.inventory.getItemInOffHand().clone();
source.core.inventory.setItemInOffHand(null); fixture.changed(source);
target.core.inventory.setItemInOffHand(null); fixture.changed(target);
awaitCleanExit(fixture, sessions, source);
awaitCleanExit(fixture, sessions, target);
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
fixture.plugin.setEnabled(true);
var resumed = (games.dmg.fantasystick.combat.BasicAttack)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "basicAttack");
var stale = new games.dmg.fantasystick.session.ControlInput(source.player, old, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target.player, 0, 1,
() -> false);
resumed.accept(stale); resumed.accept(stale);
assertEquals(1, requests.get(), "Stale/duplicate notifications cannot replay native damage");
var recoveredSessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
awaitOrdinaryArrival(fixture, recoveredSessions, source);
awaitOrdinaryArrival(fixture, recoveredSessions, target);
enterAfterRestoredExit(fixture, recoveredSessions, source, sheet);
enterAfterRestoredExit(fixture, recoveredSessions, target, targetSheet);
assertTrue(recoveredSessions.active(target.core.id).isPresent(),
"Fresh entry after proved ordinary arrival must own a current target session");
source.core.inventory.getInventory().setSelectedSlot(1);
attack(fixture, source, target);
assertEquals(2, requests.get(), "Fresh session gets a fresh action after restart");
fixture.plugin.setEnabled(false);
var entries = records();
assertEquals(2, entries.stream().filter(value -> is(value, "combat.damage")).count());
assertEquals(2, actions(entries, "action.resolved").stream()
.filter(value -> value.getAsJsonObject("data").get("reason").getAsString().equals("stale_input")).count());
assertEquals(4, actions(entries, "action.observed").size());
}
}
@Test void mixedModeAndPriorNativeVetoNeverConsumeCadenceOrPublishDamage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var ordinary = actor(fixture);
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
var priorVeto = new java.util.concurrent.atomic.AtomicBoolean(false);
var nativeHits = new ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, ordinary.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, nativeSource,
(Double) call.getArgument(0));
if (priorVeto.get()) event.setCancelled(true);
nativeHits.add(event);
fixture.dispatcher.accept(event);
return null;
}).when(ordinary.player).damage(anyDouble(), same(source.player));
attack(fixture, source, ordinary); // Fantasy against ordinary: native mixed-mode veto.
assertTrue(nativeHits.getFirst().isCancelled());
fixture.activate(ordinary);
priorVeto.set(true); attack(fixture, source, ordinary);
assertTrue(nativeHits.getLast().isCancelled(), "Pre-existing native veto is never uncancelled");
priorVeto.set(false); attack(fixture, source, ordinary);
assertFalse(nativeHits.getLast().isCancelled(), "Neither refusal spent the first accepted cadence");
assertEquals(3, nativeHits.size());
fixture.plugin.setEnabled(false);
var results = actions(records(), "action.resolved");
assertEquals(List.of("cancelled", "cancelled", "accepted"), results.stream()
.map(value -> value.getAsJsonObject("data").get("outcome").getAsString()).toList());
assertEquals("prior_cancelled", results.get(1).getAsJsonObject("data").get("reason").getAsString());
assertTrue(results.get(1).getAsJsonObject("data").get("native_prior_cancelled").getAsBoolean());
assertFalse(results.getFirst().getAsJsonObject("data").get("native_prior_cancelled").getAsBoolean());
assertEquals(1, records().stream().filter(value -> is(value, "combat.damage")).count());
assertTrue(results.stream().allMatch(value -> value.getAsJsonObject("data").get("source_location") != null
&& value.getAsJsonObject("data").get("target_location") != null));
}
}
@Test void strengthBoundariesReachOneOwnedNativeDamageRecordEach() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var target = actor(fixture); fixture.activate(target);
int[] strengths = {0, 9, 10, 11, 12, 13, 14, 15};
double[] expected = {1, 1, 1, 1, 2, 2, 3, 3};
for (int i = 0; i < strengths.length + 1; i++) {
var source = actor(fixture);
strength(source, i < strengths.length ? java.math.BigInteger.valueOf(strengths[i])
: java.math.BigInteger.TEN.pow(1000));
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
double requested = i < strengths.length ? expected[i] : Double.MAX_VALUE;
doAnswer(call -> {
assertEquals(requested, (Double) call.getArgument(0));
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target.player, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, requested));
return null;
}).when(target.player).damage(eq(requested), same(source.player));
attack(fixture, source, target);
source.core.inventory.setItemInOffHand(null); fixture.changed(source);
fixture.awaitCharacterAndReceipt(source);
}
fixture.plugin.setEnabled(false);
var entries = records();
var hits = entries.stream().filter(value -> is(value, "combat.damage")).map(value ->
value.getAsJsonObject("data")).toList();
assertEquals(9, hits.size());
assertEquals(List.of("1.0", "1.0", "1.0", "1.0", "2.0", "2.0", "3.0", "3.0",
Double.toString(Double.MAX_VALUE)), hits.stream()
.map(value -> value.get("requested_amount").getAsString()).toList());
assertEquals(9, hits.stream().map(value -> value.get("action_id").getAsString()).distinct().count());
assertTrue(hits.stream().allMatch(value -> value.get("final_amount").getAsString()
.equals(value.get("requested_amount").getAsString())));
}
}
private static void enterAfterRestoredExit(FantasyStickLifecycleNativeTest.Fixture fixture,
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.inventory.ItemStack sheet) throws Exception {
assertTrue(actor.core.inventory.getItemInOffHand() == null
|| actor.core.inventory.getItemInOffHand().isEmpty(),
"Observe the ordinary empty offhand before a fresh sheet move");
fixture.changed(actor); // First RESTORED observation is not a new entry request.
actor.core.inventory.setItemInOffHand(sheet);
fixture.changed(actor);
for (int i = 0; i < 80 && (sessions.active(actor.core.id).isEmpty()
|| actor.core.marker() == null || !actor.core.marker().endsWith("|PACKED")); i++) {
var task = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "Fresh entry has no callback after proved restored exit; feedback=" + actor.messages);
task.run();
}
assertTrue(sessions.active(actor.core.id).isPresent(), "Fresh entry must own a current session");
assertTrue(actor.core.marker().endsWith("|PACKED"), "Native equipment must be packed for the new session");
}
private static void awaitCleanExit(FantasyStickLifecycleNativeTest.Fixture fixture,
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var transitioning = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "transitioning");
for (int i = 0; i < 80; i++) {
if (sessions.active(actor.core.id).isEmpty() && !transitioning.containsKey(actor.core.id)
&& restoredExitProof(fixture, actor)) return;
var task = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "No exit callback before acknowledged receipt and native RESTORED marker");
task.run();
}
fail("Session exit did not clear owned native marker and acknowledged receipt before restart");
}
private static void awaitOrdinaryArrival(FantasyStickLifecycleNativeTest.Fixture fixture,
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var pending = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "pending");
var transitioning = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "transitioning");
for (int i = 0; i < 80 && (pending.containsKey(actor.core.id)
|| transitioning.containsKey(actor.core.id)); i++) fixture.tick();
assertFalse(pending.containsKey(actor.core.id), "Startup arrival must settle before a new entry request");
assertFalse(transitioning.containsKey(actor.core.id), "No unresolved native transition may be replaced");
assertTrue(sessions.active(actor.core.id).isEmpty(), "An ordinary holder cannot have a recovered active session");
assertTrue(restoredExitProof(fixture, actor),
"A completed receipt and matching RESTORED native marker are required before re-entry");
}
private static boolean restoredExitProof(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
return fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS)
.filter(state -> state.pending() == null && state.lastExit() != null)
.map(state -> ("1|" + state.lastExit().sessionId() + "|" + state.lastExit().characterId()
+ "|RESTORED").equals(actor.core.marker()))
.orElse(false);
}
private static void attack(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor source,
FantasyStickLifecycleNativeTest.Actor target) {
var event = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target.player, true);
fixture.dispatcher.accept(event);
assertTrue(event.isCancelled());
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, int value) throws Exception {
strength(actor, java.math.BigInteger.valueOf(value));
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger value) throws Exception {
strength(actor, value, null);
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger value,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, value);
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(), passives == null ? old.passives() : passives,
old.cooldowns(), old.restrictedKills(),
false, null, null, old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()),
old.revision());
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
return actor;
}
private static boolean is(JsonObject event, String suffix) {
return event.get("type").getAsString().endsWith(suffix);
}
private static List<JsonObject> actions(List<JsonObject> entries, String suffix) {
return entries.stream().filter(value -> is(value, suffix)
&& value.getAsJsonObject("data").has("action_kind")
&& value.getAsJsonObject("data").get("action_kind").getAsString().equals("basic_attack")).toList();
}
private List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return result;
// Only date-segment directories contain exported records. Walking the entire tree races the
// writer's create/move/delete of transient files under events/pending during the live poll.
try (var days = Files.list(directory)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return result;
}
}
@@ -40,17 +40,74 @@ final class FishingQuestNativeTest {
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}var rod=actor.core.inventory.getItem(0);assertEquals(Material.FISHING_ROD,rod.getType());assertEquals(quest.id(),AdventureQuestNavigation.fishingQuest(rod));assertTrue(rod.getEnchantments().isEmpty());
var use=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,rod,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(use);assertNotEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand(),"owned quest rod keeps native cast/reel mechanics");
var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged");actor.core.inventory.setItem(0,rod);
var forgedHook=mock(FishHook.class);when(forgedHook.getUniqueId()).thenReturn(UUID.randomUUID());when(forgedHook.getLocation()).thenReturn(actor.core.location.clone());var forgedCatch=mock(Item.class);var forgedEvent=new PlayerFishEvent(actor.player,forgedCatch,forgedHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);f.dispatcher.accept(forgedEvent);assertTrue(forgedEvent.isCancelled());assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
var forgedHook=mock(FishHook.class);when(forgedHook.getUniqueId()).thenReturn(UUID.randomUUID());when(forgedHook.getLocation()).thenReturn(actor.core.location.clone());var forgedCatch=mock(Item.class);var forgedEvent=new PlayerFishEvent(actor.player,forgedCatch,forgedHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);f.dispatcher.accept(forgedEvent);assertTrue(forgedEvent.isCancelled());assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));assertTrue(actor.messages.getLast().contains("cast is no longer valid"));
var entityHook=mock(FishHook.class);when(entityHook.getUniqueId()).thenReturn(UUID.randomUUID());when(entityHook.getLocation()).thenReturn(actor.core.location.clone());f.dispatcher.accept(new PlayerFishEvent(actor.player,null,entityHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));var hookedEntity=mock(Zombie.class);var entityCatch=new PlayerFishEvent(actor.player,hookedEntity,entityHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_ENTITY);f.dispatcher.accept(entityCatch);assertTrue(entityCatch.isCancelled());verify(entityHook).remove();
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);var caughtStack=new ItemStack(Material.COD);when(caught.getItemStack()).thenReturn(caughtStack);when(caught.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));var caughtEvent=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);caughtEvent.setExpToDrop(7);f.dispatcher.accept(caughtEvent);assertTrue(caughtEvent.isCancelled());assertEquals(0,caughtEvent.getExpToDrop());verify(caught).remove();await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Caught Silver Scale")));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.LURED));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
var caughtEvent=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);caughtEvent.setExpToDrop(7);f.dispatcher.accept(caughtEvent);assertTrue(caughtEvent.isCancelled());assertEquals(0,caughtEvent.getExpToDrop());verify(caught).remove();verify(hook).remove();await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Caught Silver Scale")));
var missedHook=mock(FishHook.class);when(missedHook.getUniqueId()).thenReturn(UUID.randomUUID());when(missedHook.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,missedHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,missedHook,EquipmentSlot.HAND,PlayerFishEvent.State.FAILED_ATTEMPT));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,missedHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
assertTrue(actor.messages.getLast().contains("cast is no longer valid"));
var outHook=mock(FishHook.class);when(outHook.getUniqueId()).thenReturn(UUID.randomUUID());var outLocation=actor.core.location.clone();outLocation.setX(100);when(outHook.getLocation()).thenReturn(outLocation);
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,outHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
var outsideBobber=new PlayerFishEvent(actor.player,caught,outHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);f.dispatcher.accept(outsideBobber);
assertTrue(outsideBobber.isCancelled());assertTrue(actor.messages.getLast().contains("yourself and the bobber inside"));
var groundHook=mock(FishHook.class);when(groundHook.getUniqueId()).thenReturn(UUID.randomUUID());when(groundHook.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,groundHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,groundHook,EquipmentSlot.HAND,PlayerFishEvent.State.IN_GROUND));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,groundHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
assertTrue(actor.messages.getLast().contains("cast is no longer valid"));
var cancelledHook=mock(FishHook.class);when(cancelledHook.getUniqueId()).thenReturn(UUID.randomUUID());when(cancelledHook.getLocation()).thenReturn(actor.core.location.clone());
var cancelledCast=new PlayerFishEvent(actor.player,null,cancelledHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING);cancelledCast.setCancelled(true);f.dispatcher.accept(cancelledCast);
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,cancelledHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
assertTrue(actor.messages.getLast().contains("cast is no longer valid"));
var emptyHook=mock(FishHook.class);when(emptyHook.getUniqueId()).thenReturn(UUID.randomUUID());when(emptyHook.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,emptyHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
var emptyCatch=new PlayerFishEvent(actor.player,null,emptyHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);f.dispatcher.accept(emptyCatch);
assertTrue(emptyCatch.isCancelled());assertTrue(actor.messages.getLast().contains("no native fish item"));
assertEquals(BigInteger.ONE,fishing.quantity(actor.core.record.id(),site.fishing().itemId()),"refused catches award no item");
doReturn(true).when(actor.player).isSneaking();int books=actor.books.size();var sneak=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(0),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(sneak);await(f,()->actor.books.size()>books);var pages=((org.bukkit.inventory.meta.BookMeta)actor.books.getLast().getItemMeta()).pages().stream().map(PlainTextComponentSerializer.plainText()::serialize).toList();assertTrue(String.join("\n",pages).contains("Inventory"),pages.toString());assertTrue(String.join("\n",pages).contains("Golden Scale: 1"),pages.toString());assertTrue(String.join("",pages).replace("\n","").contains(site.id().toString()),pages.toString());doReturn(false).when(actor.player).isSneaking();
controller.open(actor.player);drain(f);var offers=actor.inventories.getLast();assertTrue(plain(offer(offers,"Moonlit Pond")).stream().anyMatch(line->line.contains("Held: 1 / 1")));var turnIn=java.util.stream.IntStream.range(0,offers.getSize()).mapToObj(offers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,offers,turnIn);var turnMenu=actor.inventories.getLast();var submit=offer(turnMenu,"Submit Silver Scale");assertTrue(plain(submit).stream().anyMatch(line->line.contains("Beneficiaries: 1")));var fishingController=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");clickFishing(fishingController,actor,turnMenu,submit);clickFishing(fishingController,actor,turnMenu,submit);assertTrue(actor.messages.getLast().contains("menu, character or session changed"));await(f,()->adventures.active().isEmpty()&&fishing.quantity(actor.core.record.id(),site.fishing().itemId()).signum()==0);assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()));
controller.open(actor.player);drain(f);var offers=actor.inventories.getLast();assertTrue(plain(offer(offers,"Moonlit Pond")).stream().anyMatch(line->line.contains("Held: 1 / 1")),plain(offer(offers,"Moonlit Pond")).toString());var turnIn=java.util.stream.IntStream.range(0,offers.getSize()).mapToObj(offers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,offers,turnIn);var turnMenu=actor.inventories.getLast();var submit=offer(turnMenu,"Submit Silver Scale");assertTrue(plain(submit).stream().anyMatch(line->line.contains("Beneficiaries: 1")));var fishingController=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");clickFishing(fishingController,actor,turnMenu,submit);clickFishing(fishingController,actor,turnMenu,submit);assertTrue(actor.messages.getLast().contains("menu, character or session changed"));await(f,()->adventures.active().isEmpty()&&fishing.quantity(actor.core.record.id(),site.fishing().itemId()).signum()==0);assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()));
var completedGeneration=catalog.revision();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(completedGeneration));assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()),"full reroll preserves earned shares");
controller.open(actor.player);drain(f);var emptyOffers=actor.inventories.getLast();turnIn=java.util.stream.IntStream.range(0,emptyOffers.getSize()).mapToObj(emptyOffers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,emptyOffers,turnIn);turnMenu=actor.inventories.getLast();var claim=offer(turnMenu,"Claim earned quest rewards");clickFishing((FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController"),actor,turnMenu,claim);await(f,()->fishing.unclaimed(actor.core.record.id()).equals(FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(12),saved.experience());assertEquals(actor.core.record.coins().add(BigInteger.valueOf(4)),saved.coins());
}
}
@Test void ordinaryQuestCatchRetractsHookAndNextCastSurvivesOldHookCallback() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);doReturn(null).when(actor.player).getFishHook();
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var site=adventures.placeFishingSite("Stillwater","Rare Pearl",world,0,64,0,16,java.math.BigDecimal.ZERO,BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
var before=adventures.snapshot().catalog();var roll=adventures.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
adventures.accept(roll.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
assertEquals(Material.FISHING_ROD,actor.core.inventory.getItem(0).getType());
var first=mock(FishHook.class);when(first.getUniqueId()).thenReturn(UUID.randomUUID());when(first.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,first,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,first,EquipmentSlot.HAND,PlayerFishEvent.State.LURED));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,first,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
var vetoed=new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);vetoed.setCancelled(true);f.dispatcher.accept(vetoed);verify(first,never()).remove();
var firstCatch=new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);firstCatch.setExpToDrop(6);f.dispatcher.accept(firstCatch);
assertTrue(firstCatch.isCancelled());assertEquals(0,firstCatch.getExpToDrop());verify(caught).remove();verify(first).remove();
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));assertTrue(actor.messages.stream().anyMatch(message->message.contains("no quest item")));
var next=mock(FishHook.class);when(next.getUniqueId()).thenReturn(UUID.randomUUID());when(next.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
verify(next,never()).remove();
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.LURED));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,next,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
verify(next).remove();await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.valueOf(4)));
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
}
}
@Test void nonleaderMemberSuppliesWholeRequirementAndOfflinePartySharePersists() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
@@ -104,6 +104,228 @@ class FrostShardNativeTest {
assertEquals(1, damages.size());
verify(projectile, atLeastOnce()).remove();
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
for (var suffix : java.util.List.of("ability.learned", "ability.assigned", "ability.cast",
"ability.resolved", "combat.damage", "effect.applied", "effect.ended"))
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count(), suffix);
var castEvent = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("12", castEvent.get("captured_int").getAsString());
assertEquals("3.0", castEvent.getAsJsonObject("captured_settings").get("projectile-speed").getAsString());
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(castEvent.get("action_id").getAsString(), damage.get("action_id").getAsString());
assertEquals(castEvent.get("execution_id").getAsString(), damage.get("execution_id").getAsString());
var resolution = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(resolution.get("projectile_id").getAsString(), damage.get("projectile_id").getAsString());
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
assertEquals(5, damage.get("requested_amount").getAsDouble());
assertEquals(5, damage.get("actual_amount").getAsDouble());
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
assertTrue(damage.has("direct_entity_id"));
}
@Test void staleFlightIsInterruptedRatherThanFalselyReportedAsAnOrdinaryMiss() throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
var frost = new games.dmg.fantasystick.ability.FrostShard(
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(),
mock(games.dmg.fantasystick.presentation.ParticleDelivery.class));
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
when(execution.stillCurrent()).thenReturn(true, false);
when(execution.definition()).thenReturn(frost.definition());
when(execution.input()).thenReturn(input);
when(execution.character()).thenReturn(actor.core.record);
when(input.player()).thenReturn(actor.player);
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
doReturn(eye).when(actor.player).getEyeLocation();
var arrow = projectile(actor, new org.bukkit.Location(actor.world, 2, 65.6, 0));
doReturn(arrow).when(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
frost.activate(execution);
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow));
verify(execution).resolve(eq("interrupted"), anyMap());
frost.close();
}
@Test void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch() throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
var particles = mock(games.dmg.fantasystick.presentation.ParticleDelivery.class);
var frost = new games.dmg.fantasystick.ability.FrostShard(
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(), particles);
var captured = frost.definition();
frost.configure(new games.dmg.fantasystick.ability.FrostShard.Settings(BigInteger.valueOf(9),
java.time.Duration.ofSeconds(5), 32, java.time.Duration.ofSeconds(5), 4, 0,
java.time.Duration.ofSeconds(2)));
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
when(execution.stillCurrent()).thenReturn(true);
when(execution.definition()).thenReturn(captured);
when(execution.input()).thenReturn(input);
when(execution.character()).thenReturn(actor.core.record);
when(input.player()).thenReturn(actor.player);
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
doReturn(eye).when(actor.player).getEyeLocation();
var arrow = projectile(actor, new org.bukkit.Location(actor.world, 2, 65.6, 0));
doReturn(arrow).when(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(execution.eligible(any(), eq(1))).thenReturn(java.util.List.of(target));
frost.activate(execution);
var velocity = org.mockito.ArgumentCaptor.forClass(org.bukkit.util.Vector.class);
verify(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), velocity.capture());
assertEquals(3.0, velocity.getValue().length(), 0.0001, "Pending cast retains the captured default speed");
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
var amount = org.mockito.ArgumentCaptor.forClass(double.class);
verify(execution).damage(eq(target), amount.capture(), any(org.bukkit.damage.DamageSource.class));
assertEquals(games.dmg.fantasystick.ability.FrostShard.damage(actor.core.record.stats()
.get(games.dmg.fantasystick.progression.Stat.INT)).doubleValue(), amount.getValue());
frost.close();
}
@Test void registeredFrostShardConfigReadAndAcknowledgedSaveReachJsonlWithoutFailedWrite() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage"});
assertTrue(admin.messages.getLast().contains("frost-shard-base-damage=4"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "11"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage"});
f.plugin.setEnabled(false);
}
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")
&& (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
assertEquals(2, config.size(), "Denied and failed attempts cannot fabricate acknowledged changes");
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
}
@Test void acceptedSlowExportsItsOwnedApplicationAndTheActuallyRestoredPriorLayer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
prepareLaunches(f, a, arrow);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
var prior = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 100, 1);
var current = new java.util.concurrent.atomic.AtomicReference<>(prior);
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(ignored -> current.get());
doAnswer(call -> { current.set(call.getArgument(0)); return true; }).when(target).addPotionEffect(any());
doAnswer(call -> { current.set(null); return null; }).when(target).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
assertNotNull(current.get()); assertEquals(40, current.get().getDuration());
f.delayed.getLast().run();
assertNotNull(current.get()); assertEquals(1, current.get().getAmplifier());
f.plugin.setEnabled(false);
}
var effects = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")
&& (event.get("type").getAsString().endsWith("effect.applied")
|| event.get("type").getAsString().endsWith("effect.ended"))).toList();
assertEquals(2, effects.size());
var applied = effects.getFirst().getAsJsonObject("data");
var ended = effects.getLast().getAsJsonObject("data");
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
assertEquals("replaced", applied.get("prior_layer").getAsString());
assertEquals(1, applied.get("prior_amplifier").getAsInt());
assertEquals("expired", ended.get("reason").getAsString());
assertEquals("prior", ended.get("restored_layer").getAsString());
}
@Test void newerNativeSlowLayerSurvivesOwnedCleanupAndIsNotReportedAsRestoredPrior() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
var current = new java.util.concurrent.atomic.AtomicReference<org.bukkit.potion.PotionEffect>();
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(ignored -> current.get());
doAnswer(call -> { current.set(call.getArgument(0)); return true; }).when(target).addPotionEffect(any());
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
var newer = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 200, 3);
current.set(newer);
f.delayed.getLast().run();
assertSame(newer, current.get());
verify(target, never()).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
f.plugin.setEnabled(false);
}
var ended = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("superseded", ended.get("reason").getAsString());
assertEquals("preserved_newer", ended.get("restored_layer").getAsString());
}
@Test void naturallyExpiredOwnedSlowDoesNotClaimSupersessionOrInventRestoration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
when(target.addPotionEffect(any())).thenReturn(true);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
f.delayed.getLast().run();
verify(target, never()).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
f.plugin.setEnabled(false);
}
var ended = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("expired", ended.get("reason").getAsString());
assertEquals("none", ended.get("restored_layer").getAsString());
}
@Test void partyMixedModeAndCancelledDamageApplyNeitherDamageNorSlow() throws Exception {
@@ -138,6 +360,77 @@ class FrostShardNativeTest {
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(third, target));
verify(target, never()).addPotionEffect(any());
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
assertEquals(3, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|| event.get("type").getAsString().endsWith("effect.applied")).count());
assertEquals(3, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
}
@Test void acceptedFantasyPlayerFreezeHasOneOwnerNotAnIncomingNativeDuplicate() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.location.setWorld(a.world); b.core.location.setX(2); b.core.location.setY(65.6);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageEvent(b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FALL, 1));
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
prepareLaunches(f, a, arrow);
doReturn(true).when(b.player).addPotionEffect(any());
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
(Double) call.getArgument(0));
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player));
verify(b.player, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("target_player_id")
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
assertEquals(2, damage.size(), "One unrelated fall and one Freeze; no duplicate incoming Freeze");
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).count());
}
}
@Test void zeroFinalNativeDamageIsNotAnAcceptedHitOrAFalseCancellation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, 0);
f.dispatcher.accept(hit);
assertFalse(hit.isCancelled(), "Zero-damage native event was not itself cancelled");
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
verify(target, never()).addPotionEffect(any());
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("miss")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|| event.get("type").getAsString().endsWith("effect.applied")).count());
}
@Test void rangeLifetimeAndSessionEndRemoveOwnedProjectilesWithoutLateDamage() throws Exception {
@@ -174,6 +467,14 @@ class FrostShardNativeTest {
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
verify(exiting, atLeastOnce()).remove();
}
var outcomes = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
assertEquals(4, outcomes.size(), outcomes.toString());
assertEquals(2, outcomes.stream().filter("miss"::equals).count(),
"The outside Arrow and non-accepted boundary damage must not claim hits: " + outcomes);
assertTrue(outcomes.containsAll(java.util.List.of("expired", "interrupted")), outcomes.toString());
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedProjectile() throws Exception {
@@ -212,6 +513,73 @@ class FrostShardNativeTest {
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|| event.get("type").getAsString().endsWith("effect.applied")).count());
}
@Test void restartedDifferentHolderCannotRelaunchTheSameCommittedFrostDeadline() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 60\n");
org.bukkit.inventory.ItemStack sheet;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
cast(f, a);
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
f.plugin.setEnabled(false);
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b);
cast(f, b);
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|| event.get("type").getAsString().endsWith("effect.applied")).count());
}
@Test void refusedCooldownWriteExportsFailureWithoutProjectileOrEffect() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.close();
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(response); response.run();
}
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
verify(a.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|| event.get("type").getAsString().endsWith("combat.damage")
|| event.get("type").getAsString().endsWith("effect.applied")).count());
}
@Test void failedCooldownPersistenceLaunchesNoProjectileOrSlow() {
@@ -470,6 +838,20 @@ class FrostShardNativeTest {
}
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
try (var segments = java.nio.file.Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
}
}
return results;
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
@@ -14,6 +14,160 @@ class GreatSwordNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredBookAndContainedStrikeExportOneCorrelatedAcceptedDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
withStrength(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
var learn = abilityButton(a, "[Learn]", "great_sword");
assertNotNull(learn);
PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
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.setDamage(6.5); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var contained = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(contained); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(contained.isCancelled());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f);
verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
var owner = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
assertEquals(1, owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
var casts = owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).toList();
assertEquals(1, casts.size());
var cast = casts.getFirst().getAsJsonObject("data");
assertEquals("12", cast.get("captured_str").getAsString());
assertEquals("1.0", cast.get("captured_charge").getAsString());
assertEquals("7", cast.getAsJsonObject("captured_settings").get("base-damage").getAsString());
assertTrue(cast.getAsJsonObject("target_location").has("world_id"));
assertTrue(cast.get("cooldown_until").getAsString().endsWith("Z"));
var resolutions = owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")).toList();
assertEquals(2, resolutions.size());
assertEquals(1, resolutions.stream().filter(event -> event.getAsJsonObject("data")
.get("outcome").getAsString().equals("hit")).count());
assertEquals(1, resolutions.stream().filter(event -> event.getAsJsonObject("data")
.get("outcome").getAsString().equals("refused")
&& event.getAsJsonObject("data").get("reason").getAsString().equals("cooldown_active")).count());
var damage = owner.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).toList();
assertEquals(1, damage.size(), "Only the accepted custom hit is exported, never vanilla continuation");
assertEquals(8, damage.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
assertEquals(6.5, damage.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble());
assertEquals(cast.get("action_id").getAsString(), damage.getFirst().getAsJsonObject("data").get("action_id").getAsString());
assertEquals("8", damage.getFirst().getAsJsonObject("data").get("full_amount").getAsString());
assertEquals("8.0", damage.getFirst().getAsJsonObject("data").get("scaled_requested_amount").getAsString());
assertEquals(target.getUniqueId().toString(), damage.getFirst().getAsJsonObject("data").get("target_entity_id").getAsString());
assertTrue(damage.getFirst().getAsJsonObject("data").getAsJsonObject("target_location").has("world_id"));
}
}
@Test void registeredAdminGreatSwordConfigReadAndAcknowledgedUpdateExportOnlyCommittedChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage"});
assertTrue(admin.messages.getLast().contains("great-sword-base-damage=7"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(response, "A save must acknowledge before runtime publication"); response.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("great_sword").orElseThrow().parameters().get("base-damage"));
assertTrue(java.nio.file.Files.readString(data.resolve("config.yml")).contains("great-sword-base-damage"));
try (var lockFile = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = lockFile.lock()) {
assertTrue(lock.isValid());
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "11"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals(new java.math.BigDecimal("9"), registry.find("great_sword").orElseThrow().parameters().get("base-damage"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "-1"});
assertTrue(admin.messages.getLast().contains("invalid"));
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage"});
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "13"});
assertEquals(java.util.List.of(), f.command.tabComplete(admin.port, "fstickadmin",
new String[]{"config", "great-sword-base"}));
admin.allowed = true;
assertEquals(java.util.List.of("great-sword-base-damage"), f.command.tabComplete(admin.port, "fstickadmin",
new String[]{"config", "great-sword-base"}));
f.plugin.setEnabled(false);
}
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().equals("great-sword-base-damage")
&& (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
assertEquals(2, config.size(), "Read and committed update only; no failed-write success");
assertEquals("7", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
}
@Test void acknowledgedRecoverySettingAffectsOnlyFutureStrikesAndSurvivesRestart() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var originalDeadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword");
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-recovery-seconds"});
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-recovery-seconds", "2"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(java.time.Duration.ofSeconds(2), registry.find("great_sword").orElseThrow().cooldown());
assertEquals(originalDeadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
f.plugin.setEnabled(false);
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(java.time.Duration.ofSeconds(2), registry.find("great_sword").orElseThrow().cooldown());
f.plugin.setEnabled(false);
}
var changes = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")
&& event.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
assertEquals("great-sword-recovery-seconds", changes.getFirst().getAsJsonObject("data").get("key").getAsString());
assertEquals("0.75", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
assertEquals("2", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
}
@Test void bookAcquisitionAndFullyChargedEquippedStrikeReplaceVanillaDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -128,6 +282,8 @@ class GreatSwordNativeTest {
when(a.player.getAttackCooldown()).thenReturn(0.5f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var damages = new java.util.ArrayList<Double>();
doAnswer(call -> {
double amount = call.getArgument(0); damages.add(amount);
@@ -142,6 +298,48 @@ class GreatSwordNativeTest {
assertTrue(attack.isCancelled());
assertEquals(java.util.List.of(3.2), damages);
}
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, damage.size());
assertEquals(3.2, damage.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
assertEquals("8", damage.getFirst().getAsJsonObject("data").get("full_amount").getAsString());
assertEquals("0.5", damage.getFirst().getAsJsonObject("data").get("charge").getAsString());
}
@Test void acceptedFantasyPlayerStrikeHasOneOwnerNotAnIncomingNativeDuplicate() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.location.setWorld(a.world);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
doReturn(org.bukkit.entity.EntityType.PLAYER).when(a.player).getType();
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageEvent(b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FALL, 1));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
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));
f.dispatcher.accept(event);
assertFalse(event.isCancelled()); return null;
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
verify(b.player, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("target_player_id")
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
assertEquals(2, damage.size(), "One ordinary fall observation plus one strike, no second incoming strike");
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).count());
}
}
@Test void refusedPlayersAndCancelledNativeDamagePreserveProtectionWithoutFreeVanillaHits() throws Exception {
@@ -157,6 +355,7 @@ class GreatSwordNativeTest {
assertTrue(parties.accept(b.core.id,
parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
when(a.player.getAttackCooldown()).thenReturn(1.0f);
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
a.core.inventory.getInventory().setSelectedSlot(2);
var partyAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true);
@@ -166,6 +365,8 @@ class GreatSwordNativeTest {
var ordinary = mock(org.bukkit.entity.Player.class);
when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
doReturn(org.bukkit.entity.EntityType.PLAYER).when(ordinary).getType();
when(ordinary.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var ordinaryAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, ordinary, true);
f.dispatcher.accept(ordinaryAttack); PartyBookNativeTest.drain(f);
assertTrue(ordinaryAttack.isCancelled());
@@ -196,6 +397,17 @@ class GreatSwordNativeTest {
PartyBookNativeTest.drain(f);
assertEquals(BigInteger.ZERO, storage.find(a.core.record.id()).get().orElseThrow().experience());
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count(),
"Refused friendly and ordinary targets spend no recovery");
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
"A cancelled hit never claims accepted custom damage");
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")
&& event.getAsJsonObject("data").get("reason").getAsString().equals("target_ineligible")).count());
}
@Test void unlearnRelearnAndWholeCharacterTransferRetainTheOriginalRecovery() throws Exception {
@@ -208,6 +420,8 @@ class GreatSwordNativeTest {
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
hits.incrementAndGet();
@@ -257,6 +471,61 @@ class GreatSwordNativeTest {
assertEquals(1, hits.get(), "Transfer cannot reset the character-bound recovery");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
"Retry, relearn and transferred holder cannot claim another strike before the retained deadline");
}
@Test void restartAndDifferentHolderCannotReapplyACompletedChargedStrike() throws Exception {
org.bukkit.inventory.ItemStack sheet;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
f.plugin.setEnabled(false);
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b);
when(b.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(b.world, 4, 64, 0));
b.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
PartyBookNativeTest.drain(f);
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
}
var records = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void fractionalRecoveryRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
@@ -307,6 +576,37 @@ class GreatSwordNativeTest {
}
}
@Test void refusedCooldownSaveExportsFailedResolutionWithoutInventingDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.close();
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(response); response.run();
}
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|| event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
@@ -449,6 +749,16 @@ class GreatSwordNativeTest {
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return events;
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
@@ -83,6 +83,30 @@ class LongbowNativeTest {
assertEquals(BigInteger.TEN, saved.experience()); assertNotNull(saved.cooldowns().get("longbow"));
f.dispatcher.accept(new ProjectileHitEvent(arrow, target)); assertEquals(1, damages.size());
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
.findFirst().orElseThrow().getAsJsonObject("data");
var result = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
.findFirst().orElseThrow().getAsJsonObject("data");
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("hit", result.get("outcome").getAsString());
assertEquals("20", cast.get("draw_ticks").getAsString());
assertEquals("1", cast.get("draw_power").getAsString());
assertEquals("14", cast.get("captured_dex").getAsString());
assertTrue(cast.has("cooldown_until"));
assertTrue(cast.has("after_revision"));
assertTrue(cast.getAsJsonObject("location").has("world_id"));
assertEquals(cast.get("execution_id").getAsString(), result.get("execution_id").getAsString());
assertEquals(result.get("arrow_id").getAsString(), damage.get("arrow_id").getAsString());
assertEquals(cast.get("action_id").getAsString(), damage.get("action_id").getAsString());
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
assertEquals(7.0, damage.get("actual_amount").getAsDouble());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void partialDrawUsesReviewedCurveWhileTooShortDrawRefusesBeforeCooldown() throws Exception {
@@ -111,6 +135,16 @@ class LongbowNativeTest {
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
assertEquals(7.0 * (0.25 + 1.0) / 3.0, amount[0], 1.0e-9);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("draw_ticks").getAsInt() == 10).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")).count());
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
.map(event -> event.getAsJsonObject("data")).toList();
assertEquals(1, damage.size(), "Exactly one accepted partial-draw damage record");
assertEquals(7.0 * (0.25 + 1.0) / 3.0, damage.getFirst().get("requested_amount").getAsDouble(), 1.0e-9);
}
@Test void nativeDamagePartyTargetsAndCancelledCustomDamageProduceNoAcceptedHit() throws Exception {
@@ -151,6 +185,98 @@ class LongbowNativeTest {
assertEquals(BigInteger.ZERO, storage.find(c.core.record.id()).get().orElseThrow().experience());
assertNotNull(storage.find(c.core.record.id()).get().orElseThrow().cooldowns().get("longbow"));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count(),
"Native party protection and cancelled custom damage each resolve without an accepted hit");
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void mixedModeNativeProtectionCancelsArrowHitWithoutClaimingDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
ordinary.core.location.setWorld(a.world); ordinary.core.location.setX(4); ordinary.core.location.setY(65.6);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
f.activate(a);
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(arrow, ordinary.player));
verify(ordinary.player, never()).damage(anyDouble(), any(DamageSource.class));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void acceptedFantasyPlayerArrowDamageIsNotDuplicatedAsGenericIncomingDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.location.setWorld(a.world); b.core.location.setX(6); b.core.location.setY(65.6);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
f.dispatcher.accept(new EntityDamageEvent(b.player, EntityDamageEvent.DamageCause.FALL, 1));
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
doAnswer(call -> {
var source = call.getArgument(1, DamageSource.class);
var hit = new EntityDamageByEntityEvent(arrow, b.player, EntityDamageEvent.DamageCause.PROJECTILE,
source, (Double) call.getArgument(0));
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
}).when(b.player).damage(anyDouble(), any(DamageSource.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(arrow, b.player));
verify(b.player, times(1)).damage(anyDouble(), any(DamageSource.class));
f.plugin.setEnabled(false);
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("target_player_id")
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
assertEquals(2, damage.size(), "One unrelated fall and one accepted Longbow shot");
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).count());
}
}
@Test void zeroFinalNativeArrowDamageIsAReportedMissNotAnAcceptedHitOrCancellation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a);
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new Location(a.world, 4, 65.6, 0));
doAnswer(call -> {
var source = call.getArgument(1, DamageSource.class);
var hit = new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
source, 0);
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("miss")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void rangeLifetimeAndSessionEndRemoveOwnedArrowsWithoutLateDamage() throws Exception {
@@ -180,6 +306,12 @@ class LongbowNativeTest {
verify(arrow, atLeastOnce()).remove();
}
}
var results = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow"))
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
assertEquals(3, results.size(), results.toString());
assertTrue(results.containsAll(java.util.List.of("miss", "expired", "interrupted")), results.toString());
}
@Test void unlearnRelearnAndTransferRetainRecoveryAndRemoveTheOwnedArrow() throws Exception {
@@ -212,6 +344,66 @@ class LongbowNativeTest {
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void restartAndNewHolderCannotRelaunchACommittedLongbowRecovery() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "longbow-recovery-seconds: 60\n");
ItemStack sheet;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a);
var arrow = arrow(a, new Location(a.world, 2, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
verify(a.player, times(1)).launchProjectile(eq(Arrow.class), any(Vector.class));
sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
f.plugin.setEnabled(false);
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b);
drawAndRelease(f, b, 20);
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void failedNativeRecoverySaveReportsFailureWithoutArrowOrAcceptedDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.close();
drawAndRelease(f, a, 20);
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(response); response.run();
}
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
verify(a.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|| event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void failedRecoveryPersistenceLaunchesNoArrow() {
@@ -244,6 +436,38 @@ class LongbowNativeTest {
assertEquals(1, failures.size()); engine.close(); longbow.close(); parties.close();
}
@Test void registeredAuthorizedConfigurationReadAndAcknowledgedSaveReachJsonlButFailureDoesNot() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage"});
assertTrue(admin.messages.getLast().contains("longbow-base-damage=5"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage", "9"});
var saved = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(saved); saved.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
.parameters().get("base-damage"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage", "11"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage"});
f.plugin.setEnabled(false);
}
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")
&& (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
assertEquals(2, config.size(), "Denied reads and failed writes cannot claim successful config events");
assertEquals("5", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
}
@Test void acknowledgedConfigurationChangesOnlyFutureShotSnapshots() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -391,6 +615,20 @@ class LongbowNativeTest {
private record DamageTarget(LivingEntity entity, double[] amount) { }
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
try (var segments = java.nio.file.Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
}
}
return results;
}
private static DamageTarget targetRecordingDamage(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor, Arrow arrow) {
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
@@ -0,0 +1,604 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered session and native nutrition policy -> exported JSONL, not direct bridge calls. */
class NutritionEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void acknowledgedEntryAndExitExportOneOwnedNutritionTransitionEach() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
actor.player.setSaturation(2);
actor.player.setExhaustion(3);
fixture.activate(actor);
assertEquals(20, actor.player.getFoodLevel());
awaitType("nutrition.suspended");
actor.core.inventory.setItemInOffHand(null);
fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
assertEquals(7, actor.player.getFoodLevel());
awaitType("nutrition.restored");
fixture.plugin.setEnabled(false);
var suspended = matching("nutrition.suspended");
var restored = matching("nutrition.restored");
assertEquals(1, suspended.size()); assertEquals(1, restored.size());
var start = suspended.getFirst().getAsJsonObject("data");
var end = restored.getFirst().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), suspended.getFirst().get("playerid").getAsString());
assertEquals(actor.core.record.id().toString(), suspended.getFirst().get("characterid").getAsString());
assertEquals(start.get("session_id"), end.get("session_id"));
assertEquals(7, start.get("ordinary_food").getAsInt());
assertEquals(2, start.get("ordinary_saturation").getAsFloat());
assertEquals(3, start.get("ordinary_exhaustion").getAsFloat());
assertEquals(20, start.get("fantasy_food").getAsInt());
assertEquals(7, end.get("restored_food").getAsInt());
assertEquals("acknowledged", end.get("outcome").getAsString());
assertTrue(start.has("receipt_id"));
assertTrue(end.has("receipt_id"));
assertTrue(suspended.getFirst().getAsJsonObject("data").has("location"));
}
}
@Test void restartedRecoveryWithoutHealthProofRetainsNutritionWithoutInventedSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
sessions.exit(actor.player));
fixture.awaitCharacterAndReceipt(actor);
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
actor.core.inventory.setItem(0, null);
fixture.plugin.setEnabled(true);
awaitNutritionResolution(fixture, "retained", "health_unverified");
fixture.plugin.setEnabled(false);
assertEquals(0, matching("nutrition.restored").size());
assertTrue(matching("action.resolved").stream().map(event -> event.getAsJsonObject("data"))
.anyMatch(fields -> fields.has("action_kind")
&& fields.get("action_kind").getAsString().equals("nutrition_restore")
&& fields.get("reason").getAsString().equals("health_unverified")));
}
}
@Test void retainedEquipmentExitDoesNotClaimNutritionSuccessUntilProofBasedRecoveryAcknowledges() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
sessions.exit(actor.player));
fixture.awaitCharacterAndReceipt(actor);
assertEquals(0, matching("nutrition.restored").size());
var attempt = ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "owned"))
.get(actor.core.id);
assertNotNull(attempt);
for (int i = 0; i < 8 && (boolean) FantasyStickLifecycleNativeTest.Fixture.field(attempt, "saving"); i++) {
fixture.awaitCharacterAndReceipt(actor);
fixture.tick();
}
assertTrue((boolean) FantasyStickLifecycleNativeTest.Fixture.field(attempt, "nutritionRetainedWritten"),
"Refused receipt must resolve native nutrition as retained before retry");
awaitNutritionResolution(fixture, "retained");
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
actor.core.inventory.setItem(0, null);
sessions.recover(actor.player);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(1, matching("nutrition.restored").size());
assertEquals(7, matching("nutrition.restored").getFirst().getAsJsonObject("data")
.get("restored_food").getAsInt());
}
}
@Test void acknowledgedRestoreSurvivesWriterOutageWithItsOriginalEventIdentity() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
awaitType("nutrition.suspended");
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
java.util.Set<String> pending;
try (var files = Files.list(data.resolve("nutrition-event-intents"))) {
pending = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(1, pending.size(), "A restored native snapshot awaits export, not another restoration");
fixture.plugin.setEnabled(true);
awaitType("nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(pending, matching("nutrition.restored").stream()
.map(event -> event.get("id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(1, matching("nutrition.restored").size());
}
}
@Test void finalDefeatRestorationIsNotMisreportedAsLivingExit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(actor.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
fixture.dispatcher.accept(death);
DownedSessionNativeTest.acceptDeath(fixture, actor);
PartyBookNativeTest.drain(fixture);
assertEquals(7, actor.player.getFoodLevel());
awaitTypeWithCallbacks(fixture, "nutrition.restored");
fixture.plugin.setEnabled(false);
var restored = matching("nutrition.restored");
assertEquals(1, restored.size());
assertEquals("defeat", restored.getFirst().getAsJsonObject("data").get("reason").getAsString());
assertEquals(0, matching("character.exited").size());
}
}
@Test void orderlyDisableReplaysAcknowledgedRestorationAfterLoggerShutdown() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
awaitType("nutrition.suspended");
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
assertEquals(7, actor.player.getFoodLevel());
assertEquals(0, matching("nutrition.restored").size(),
"A best-effort native setter alone does not acknowledge the receipt");
assertEquals(1, matching("nutrition.suspended").size());
fixture.plugin.setEnabled(true);
awaitType("nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(1, matching("nutrition.restored").size());
}
}
@Test void logoutAndDisableNeverClaimTwoSuccessfulRestorations() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(actor.player,
net.kyori.adventure.text.Component.empty()));
fixture.awaitCharacterAndReceipt(actor);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(1, matching("nutrition.restored").size());
assertEquals("logout", matching("nutrition.restored").getFirst().getAsJsonObject("data")
.get("reason").getAsString());
}
}
@Test void transferredSheetUsesNewHoldersOrdinaryNutritionAndCapturedIdentity() throws Exception {
Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var first = actor(fixture);
var next = actor(fixture);
next.core.inventory.setItemInOffHand(null);
first.player.setFoodLevel(7);
next.player.setFoodLevel(11);
fixture.activate(first);
var sheet = first.core.inventory.getItemInOffHand().clone();
first.core.inventory.setItemInOffHand(null); fixture.changed(first);
fixture.awaitCharacterAndReceipt(first);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
next.core.inventory.setItemInOffHand(sheet);
fixture.activate(next);
assertEquals(20, next.player.getFoodLevel());
next.core.inventory.setItemInOffHand(null); fixture.changed(next);
fixture.awaitCharacterAndReceipt(next);
for (int i = 0; i < 250 && matching("nutrition.restored").size() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
assertEquals(2, matching("nutrition.suspended").size());
assertEquals(2, matching("nutrition.restored").size());
var transferred = matching("nutrition.suspended").stream().filter(event ->
event.get("playerid").getAsString().equals(next.core.id.toString())).findFirst().orElseThrow();
assertEquals(first.core.record.id().toString(), transferred.get("characterid").getAsString());
assertEquals(11, transferred.getAsJsonObject("data").get("ordinary_food").getAsInt());
assertEquals(7, first.player.getFoodLevel());
assertEquals(11, next.player.getFoodLevel());
}
}
@Test void exitingToEatAndReenteringRecordsTwoIndependentOrdinarySnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
var sheet = actor.core.inventory.getItemInOffHand().clone();
actor.player.setHealth(4);
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor); awaitType("nutrition.restored");
actor.player.setFoodLevel(15);
actor.core.inventory.setItemInOffHand(sheet); fixture.reenter(actor);
assertEquals(4, actor.player.getHealth());
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
for (int i = 0; i < 250 && matching("nutrition.restored").size() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var suspended = matching("nutrition.suspended");
var restored = matching("nutrition.restored");
assertEquals(2, suspended.size()); assertEquals(2, restored.size());
assertEquals(2, suspended.stream().map(event -> event.getAsJsonObject("data")
.get("session_id").getAsString()).distinct().count());
assertEquals(java.util.Set.of(7, 15), suspended.stream().map(event -> event.getAsJsonObject("data")
.get("ordinary_food").getAsInt()).collect(java.util.stream.Collectors.toSet()));
assertEquals(java.util.Set.of(7, 15), restored.stream().map(event -> event.getAsJsonObject("data")
.get("restored_food").getAsInt()).collect(java.util.stream.Collectors.toSet()));
}
}
@Test void satiatedRegainIsRefusedWithoutFoodTickSpamOrOrdinaryPlayerAudit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
var ordinary = actor(fixture);
ordinary.core.inventory.setItemInOffHand(null);
fixture.activate(actor);
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(actor.player,
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
fixture.dispatcher.accept(exhaustion);
assertTrue(exhaustion.isCancelled());
var foodHeal = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
fixture.dispatcher.accept(foodHeal);
assertTrue(foodHeal.isCancelled());
var ordinaryHeal = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
fixture.dispatcher.accept(ordinaryHeal);
assertFalse(ordinaryHeal.isCancelled());
awaitType("action.resolved");
fixture.plugin.setEnabled(false);
var resolved = matching("action.resolved").stream().filter(event ->
event.getAsJsonObject("data").has("action_kind")
&& event.getAsJsonObject("data").get("action_kind").getAsString().equals("satiated_regeneration"))
.toList();
assertEquals(1, resolved.size());
assertEquals("cancelled", resolved.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertEquals(2, resolved.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
assertTrue(resolved.getFirst().getAsJsonObject("data").has("location"));
assertEquals(0, matching("combat.healed").size());
}
}
@Test void nonOwnedNativeMagicHealingReportsOnlyAppliedAmountAfterContinuation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
var accepted = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
fixture.dispatcher.accept(accepted);
assertFalse(accepted.isCancelled());
actor.player.setHealth(6); // The actual native health result, not the requested amount.
fixture.tick();
awaitType("combat.healed");
fixture.plugin.setEnabled(false);
var healed = matching("combat.healed");
assertEquals(1, healed.size());
var payload = healed.getFirst().getAsJsonObject("data");
assertEquals("MAGIC", payload.get("regain_source").getAsString());
assertEquals(4.0, payload.get("health_before").getAsDouble());
assertEquals(6.0, payload.get("health_after").getAsDouble());
assertEquals(2.0, payload.get("actual_amount").getAsDouble());
assertEquals("pending_session_health_save", payload.get("persistence_result").getAsString());
assertEquals("native_health_observed", payload.get("publication_result").getAsString());
assertEquals(actor.core.record.id().toString(), healed.getFirst().get("characterid").getAsString());
assertTrue(payload.has("location"));
}
}
@Test void failedAuditHealthReadCannotPreventNativeSessionExit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
actor.player.setHealth(6);
var reads = new java.util.concurrent.atomic.AtomicInteger();
org.mockito.Mockito.doAnswer(call -> {
if (reads.incrementAndGet() == 1) throw new IllegalStateException("Audit health read unavailable");
return actor.core.player.getHealth();
}).when(actor.player).getHealth();
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
assertEquals(7, actor.player.getFoodLevel(), "Logging must not block ordinary nutrition restoration");
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size(), "An unavailable native result is not invented");
}
}
@Test void sameTickExitStillReportsProvedNativeHealingBeforeTheSessionIsReleased() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
actor.player.setHealth(6);
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
assertTrue(((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(policy,
"pendingNativeHealing")).containsKey(actor.core.id));
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
fixture.plugin.setEnabled(false);
assertEquals(1, matching("combat.healed").size(),
"The actual native heal must not disappear when sheet exit follows in the same tick");
assertEquals(2.0, matching("combat.healed").getFirst().getAsJsonObject("data")
.get("actual_amount").getAsDouble());
}
}
@Test void nativeHealingDisclosesPendingSaveUntilSessionExitPersistsTheActualHealth() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
actor.player.setHealth(6);
fixture.tick(); awaitType("combat.healed");
var healed = matching("combat.healed").getFirst();
assertEquals("pending_session_health_save", healed.getAsJsonObject("data")
.get("persistence_result").getAsString());
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
awaitTypeWithCallbacks(fixture, "nutrition.restored");
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
assertEquals(0, characters.find(actor.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(6)));
fixture.plugin.setEnabled(false);
assertEquals(1, matching("combat.healed").size());
}
}
@Test void externallyOwnedCustomRegainIsNotMistakenForThisPluginsHealing() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM));
actor.player.setHealth(6);
fixture.tick();
awaitType("combat.healed");
fixture.plugin.setEnabled(false);
assertEquals("CUSTOM", matching("combat.healed").getFirst().getAsJsonObject("data")
.get("regain_source").getAsString());
}
}
@Test void cancelledAndUnappliedNativeHealingAndOwnedCustomCauseDoNotMintSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
var cancelled = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
cancelled.setCancelled(true);
fixture.dispatcher.accept(cancelled);
var unapplied = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
fixture.dispatcher.accept(unapplied);
var owned = new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
policy.dispatchOwnedHealing(owned, fixture.dispatcher);
fixture.tick();
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size());
}
}
@Test void ownedHealBetweenNativeObservationAndContinuationCannotBeMisattributed() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
var policy = (games.dmg.fantasystick.session.NutritionPolicy)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "nutritionPolicy");
policy.dispatchOwnedHealing(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM), fixture.dispatcher);
actor.player.setHealth(6);
fixture.tick();
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size());
}
}
@Test void competingNativeHealObservationsCannotBeMisattributedToTheFirstRequest() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 2,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC_REGEN));
actor.player.setHealth(6);
fixture.tick();
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size(),
"An overlapping native continuation has no attributable per-effect delta");
}
}
@Test void interveningDamageCannotBeMistakenForAnActualNativeHealAmount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
fixture.activate(actor);
actor.player.setHealth(4);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityRegainHealthEvent(actor.player, 3,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC));
actor.player.setHealth(7);
var damage = new org.bukkit.event.entity.EntityDamageEvent(actor.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(damage);
assertFalse(damage.isCancelled());
actor.player.setHealth(6);
fixture.tick();
fixture.plugin.setEnabled(false);
assertEquals(0, matching("combat.healed").size());
}
}
@Test void failedNativeFoodSetterCannotClaimSuccessfulSuspensionOrRestoration() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
actor.foodMutation = () -> {
actor.foodMutation = () -> {};
throw new IllegalStateException("Injected food setter failure");
};
fixture.activate(actor);
fixture.plugin.setEnabled(false);
assertEquals(0, matching("nutrition.suspended").size());
assertEquals(0, matching("nutrition.restored").size());
}
}
@Test void incompleteNativeRestorationKeepsReceiptAndRecordsRetainedNotSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = actor(fixture);
actor.player.setFoodLevel(7);
fixture.activate(actor);
actor.foodMutation = () -> {
actor.foodMutation = () -> {};
throw new IllegalStateException("Injected restoration food setter failure");
};
actor.core.inventory.setItemInOffHand(null);
fixture.changed(actor);
fixture.awaitCharacterAndReceipt(actor);
awaitNutritionResolution(fixture, "retained");
var state = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNotNull(state.pending());
fixture.plugin.setEnabled(false);
assertEquals(0, matching("nutrition.restored").size());
}
}
private void awaitNutritionResolution(FantasyStickLifecycleNativeTest.Fixture fixture, String outcome) throws Exception {
awaitNutritionResolution(fixture, outcome, null);
}
private void awaitNutritionResolution(FantasyStickLifecycleNativeTest.Fixture fixture,
String outcome, String reason) throws Exception {
for (int i = 0; i < 250 && !nutritionResolution(outcome, reason); i++) {
var task = fixture.tasks.poll(20, java.util.concurrent.TimeUnit.MILLISECONDS);
if (task == null) Thread.sleep(20);
else task.run();
}
assertTrue(nutritionResolution(outcome, reason),
"No nutrition resolution " + outcome + "/" + reason + "; recovery=" + matching("recovery.resolved"));
}
private boolean nutritionResolution(String outcome, String reason) throws Exception {
return matching("action.resolved").stream().map(event -> event.getAsJsonObject("data"))
.anyMatch(fields -> fields.has("action_kind")
&& fields.get("action_kind").getAsString().equals("nutrition_restore")
&& fields.get("outcome").getAsString().equals(outcome)
&& (reason == null || fields.get("reason").getAsString().equals(reason)));
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
return actor;
}
private void awaitTypeWithCallbacks(FantasyStickLifecycleNativeTest.Fixture fixture, String suffix) throws Exception {
for (int i = 0; i < 250 && matching(suffix).isEmpty(); i++) {
var task = fixture.tasks.poll(20, java.util.concurrent.TimeUnit.MILLISECONDS);
if (task != null) task.run();
}
assertFalse(matching(suffix).isEmpty(), "No acknowledged " + suffix + " after native callbacks");
}
private void awaitType(String suffix) throws Exception {
for (int i = 0; i < 250 && matching(suffix).isEmpty(); i++) Thread.sleep(20);
assertFalse(matching(suffix).isEmpty(), "No exported " + suffix + " from registered native path");
}
private List<JsonObject> matching(String suffix) throws Exception {
return records().stream().filter(value -> value.get("type").getAsString().endsWith(suffix)).toList();
}
private List<JsonObject> records() throws Exception {
var found = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return found;
try (var days = Files.list(directory)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) found.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return found;
}
}
@@ -18,6 +18,15 @@ class SwitchDelayEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exportedRecordsIgnoreTransientPendingFiles() throws Exception {
var day = data.resolve("events/2026-09-26");
var pending = data.resolve("events/pending");
Files.createDirectories(day); Files.createDirectories(pending);
Files.writeString(day.resolve("0.jsonl"), "{\"type\":\"exported\"}\n");
Files.writeString(pending.resolve("transient.jsonl"), "{\"type\":\"not_exported\"}\n");
assertEquals(List.of("exported"), records().stream().map(event -> event.get("type").getAsString()).toList());
}
@Test void persistedExitAndPrepackSameOrDifferentCharacterDecisionsReachJsonl() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
@@ -147,10 +156,22 @@ class SwitchDelayEventsNativeTest {
private List<com.google.gson.JsonObject> records() throws Exception {
var events = new ArrayList<com.google.gson.JsonObject>();
try (var files = Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : Files.readAllLines(path))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
var directory = data.resolve("events");
if (!Files.exists(directory)) return events;
// Pending intents are transient and may be moved while a live writer is draining them.
try (var days = Files.list(directory)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return events;
}