Compare commits

...
6 Commits
Author SHA1 Message Date
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
11 changed files with 1669 additions and 28 deletions

No files matched your search

@@ -177,6 +177,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 +190,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();
@@ -441,7 +449,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 +470,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 +496,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);
@@ -528,11 +536,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 +554,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 +627,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);
@@ -731,6 +740,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
@@ -757,6 +769,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 +804,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 +885,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 +948,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;
@@ -1234,6 +1257,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();
@@ -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());
}
@@ -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));
}
}
@@ -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)){
@@ -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;
}
}