Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
830049b339 | ||
|
|
fcbc2814c2 | ||
|
|
897a0606ba | ||
|
|
c13c3093cf | ||
|
|
bb8d1857b4 | ||
|
|
c7fce1a53d |
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user